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
11
.gitignore
vendored
@@ -14,9 +14,15 @@
|
||||
# All documentation goes in docs/ folder (private development)
|
||||
docs/
|
||||
*.md
|
||||
TEST-CLONE.md
|
||||
!README.md
|
||||
!LICENSE
|
||||
!NOTICE
|
||||
!.env.example
|
||||
!docs/API.md
|
||||
!docs/CONFIGURATION.md
|
||||
!docs/ARCHITECTURE.md
|
||||
!docs/DEVELOPMENT.md
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
@@ -418,6 +424,11 @@ deployment/
|
||||
# =============================================================================
|
||||
docs/
|
||||
*.md
|
||||
TEST-CLONE.md
|
||||
!README.md
|
||||
!LICENSE
|
||||
!.env.example
|
||||
!docs/API.md
|
||||
!docs/CONFIGURATION.md
|
||||
!docs/ARCHITECTURE.md
|
||||
!docs/DEVELOPMENT.md
|
||||
648
README.md
@@ -1,536 +1,228 @@
|
||||
# RedFlag (Aggregator)
|
||||
# RedFlag
|
||||
|
||||
**ALPHA RELEASE - v0.1.16**
|
||||
Self-hosted update management platform for homelabs and small teams
|
||||
> **⚠️ ALPHA SOFTWARE - NOT READY FOR PRODUCTION**
|
||||
>
|
||||
> This is experimental software in active development. Features may be broken, bugs are expected, and breaking changes happen frequently. Use at your own risk, preferably on test systems only. Seriously, don't put this in production yet.
|
||||
|
||||
## Status
|
||||
**Self-hosted update management for homelabs**
|
||||
|
||||
- **Core Features Working**: Update management, agent registration, web dashboard
|
||||
- **Alpha Deployment Ready**: Setup wizard and configuration system implemented
|
||||
- **Cross-Platform Support**: Linux and Windows agents
|
||||
- **In Development**: Enhanced features and polish
|
||||
- **Alpha Software**: Expect some rough edges, backup your data
|
||||
Cross-platform agents • Web dashboard • Single binary deployment • No enterprise BS
|
||||
|
||||
## What RedFlag Is
|
||||
```
|
||||
v0.1.17 - Alpha Release
|
||||
```
|
||||
|
||||
A self-hosted, cross-platform update management platform built for homelabs and small teams:
|
||||
---
|
||||
|
||||
- Go Server Backend with PostgreSQL database
|
||||
- React Web Dashboard with real-time updates
|
||||
- Cross-Platform Agents (Linux APT/DNF/Docker, Windows Updates/Winget)
|
||||
- Secure Authentication with registration tokens and refresh tokens
|
||||
- System Monitoring with real-time status and audit trails
|
||||
- User-Adjustable Rate Limiting with TLS support
|
||||
## What It Does
|
||||
|
||||
RedFlag lets you manage software updates across all your servers from one dashboard. Track pending updates, approve installs, and monitor system health without SSHing into every machine.
|
||||
|
||||
**Supported Platforms:**
|
||||
- Linux (APT, DNF, Docker)
|
||||
- Windows (Windows Update, Winget)
|
||||
- Future: Proxmox integration planned
|
||||
|
||||
**Built With:**
|
||||
- Go backend + PostgreSQL
|
||||
- React dashboard
|
||||
- Pull-based agents (firewall-friendly)
|
||||
- JWT auth with refresh tokens
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
| Dashboard | Agent Details | Update Management |
|
||||
|-----------|---------------|-------------------|
|
||||
|  |  |  |
|
||||
|
||||
| Windows Support | History Tracking | Docker Integration |
|
||||
|-----------------|------------------|-------------------|
|
||||
|  |  |  |
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Server Deployment (Docker)
|
||||
|
||||
```bash
|
||||
# Clone and start
|
||||
git clone https://github.com/Fimeg/RedFlag.git
|
||||
cd RedFlag
|
||||
docker-compose up -d
|
||||
|
||||
# Access web UI
|
||||
open http://localhost:3000
|
||||
|
||||
# Follow setup wizard to create admin account
|
||||
```
|
||||
|
||||
The setup wizard runs automatically on first launch. It'll generate secure secrets and walk you through creating an admin account.
|
||||
|
||||
---
|
||||
|
||||
### Agent Installation
|
||||
|
||||
**Linux (one-liner):**
|
||||
```bash
|
||||
curl -sfL https://your-server.com/install | sudo bash -s -- your-registration-token
|
||||
```
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
iwr https://your-server.com/install.ps1 | iex
|
||||
```
|
||||
|
||||
**Manual installation:**
|
||||
```bash
|
||||
# Download agent binary
|
||||
wget https://your-server.com/download/linux/amd64/redflag-agent
|
||||
|
||||
# Register and install
|
||||
chmod +x redflag-agent
|
||||
sudo ./redflag-agent --server https://your-server.com --token your-token --register
|
||||
```
|
||||
|
||||
Get registration tokens from the web dashboard under **Settings → Token Management**.
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
### Alpha Features
|
||||
- Secure Server Setup: `./redflag-server --setup` with user-provided secrets
|
||||
- Registration Token System: One-time tokens for secure agent enrollment
|
||||
- Rate Limiting: User-adjustable API security with sensible defaults
|
||||
- Cross-Platform Agents: Linux and Windows with unified architecture
|
||||
- Real-Time Heartbeat: Rapid polling for interactive operations
|
||||
- Dependency Management: Safe update installation with dry-run checking
|
||||
- Audit Logging: Complete activity tracking and history
|
||||
- Proxy Support: HTTP/HTTPS/SOCKS5 proxy support for restricted networks
|
||||
✓ **Secure by Default** - Registration tokens, JWT auth, rate limiting
|
||||
✓ **Idempotent Installs** - Re-running installers won't create duplicate agents
|
||||
✓ **Real-time Heartbeat** - Interactive operations with rapid polling
|
||||
✓ **Dependency Handling** - Dry-run checks before installing updates
|
||||
✓ **Multi-seat Tokens** - One token can register multiple agents
|
||||
✓ **Audit Trails** - Complete history of all operations
|
||||
✓ **Proxy Support** - HTTP/HTTPS/SOCKS5 for restricted networks
|
||||
✓ **Native Services** - systemd on Linux, Windows Services on Windows
|
||||
|
||||
### Update Management
|
||||
- Package Managers: APT, DNF, Docker images, Windows Updates, Winget
|
||||
- Update Discovery: Automatic scanning with severity classification
|
||||
- Approval Workflow: Controlled update deployment with confirmation
|
||||
- Bulk Operations: Multi-agent management and batch operations
|
||||
- Rollback Support: Failed update tracking and retry capabilities
|
||||
|
||||
### Deployment
|
||||
- Configuration Management: CLI flags → environment → config file → defaults
|
||||
- Service Integration: systemd service management on Linux
|
||||
- Cross-Platform Installers: One-liner deployment scripts
|
||||
- Container Support: Docker and Kubernetes deployment options
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Web Dashboard │ React + TypeScript + TailwindCSS
|
||||
│ + Rate Limiting │ + Registration Token Management
|
||||
│ Web Dashboard │ React + TypeScript
|
||||
│ Port: 3000 │
|
||||
└────────┬────────┘
|
||||
│ HTTPS with TLS + User Authentication
|
||||
│ HTTPS + JWT Auth
|
||||
┌────────▼────────┐
|
||||
│ Server (Go) │ Alpha with PostgreSQL
|
||||
│ + Rate Limits │ + Registration Tokens + Setup Wizard
|
||||
│ + JWT Auth │ + Heartbeat System + Comprehensive API
|
||||
│ Server (Go) │ PostgreSQL
|
||||
│ Port: 8080 │
|
||||
└────────┬────────┘
|
||||
│ Pull-based (agents check in every 5 min) + Rapid Polling
|
||||
│ Pull-based (agents check in every 5 min)
|
||||
┌────┴────┬────────┐
|
||||
│ │ │
|
||||
┌───▼──┐ ┌──▼──┐ ┌──▼───┐
|
||||
│Linux │ │Windows│ │Linux │
|
||||
│Agent │ │Agent │ │Agent │
|
||||
│+Proxy│ │+Proxy│ │+Proxy│
|
||||
└──────┘ └───────┘ └──────┘
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
---
|
||||
|
||||
- **Go 1.21+** (for building from source)
|
||||
- **Docker & Docker Compose** (for PostgreSQL database)
|
||||
- **Linux** (server deployment platform)
|
||||
## Documentation
|
||||
|
||||
## Quick Start
|
||||
- **[API Reference](docs/API.md)** - Complete API documentation
|
||||
- **[Configuration](docs/CONFIGURATION.md)** - CLI flags, env vars, config files
|
||||
- **[Architecture](docs/ARCHITECTURE.md)** - System design and database schema
|
||||
- **[Development](docs/DEVELOPMENT.md)** - Build from source, testing, contributing
|
||||
|
||||
### 1. Server Setup (Docker - Recommended)
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://github.com/Fimeg/RedFlag.git
|
||||
cd RedFlag
|
||||
---
|
||||
|
||||
# Build agent (one-time)
|
||||
cd aggregator-agent && go mod tidy && go build -o redflag-agent cmd/agent/main.go && cd ..
|
||||
## Security Notes
|
||||
|
||||
# Start database and server (auto-configures on first run)
|
||||
docker-compose up -d
|
||||
RedFlag uses:
|
||||
- **Registration tokens** - One-time use tokens for secure agent enrollment
|
||||
- **Refresh tokens** - 90-day sliding window, auto-renewal for active agents
|
||||
- **SHA-256 hashing** - All tokens hashed at rest
|
||||
- **Rate limiting** - Configurable API protection
|
||||
- **Minimal privileges** - Agents run with least required permissions
|
||||
|
||||
# Watch setup progress (optional)
|
||||
docker-compose logs -f server
|
||||
For production deployments:
|
||||
1. Change default admin password
|
||||
2. Use HTTPS/TLS
|
||||
3. Generate strong JWT secrets (setup wizard does this)
|
||||
4. Configure firewall rules
|
||||
5. Enable rate limiting
|
||||
|
||||
# When setup is complete, access: http://localhost:8080
|
||||
# Admin: http://localhost:8080/admin
|
||||
```
|
||||
---
|
||||
|
||||
### 2. Manual Setup (Development)
|
||||
```bash
|
||||
# Build components
|
||||
make build-all
|
||||
## Current Status
|
||||
|
||||
# Start database
|
||||
docker-compose up -d postgres
|
||||
**What Works:**
|
||||
- ✅ Cross-platform agent registration and updates
|
||||
- ✅ Update scanning for all supported package managers
|
||||
- ✅ Dry-run dependency checking before installation
|
||||
- ✅ Real-time heartbeat and rapid polling
|
||||
- ✅ Multi-seat registration tokens
|
||||
- ✅ Native service integration (systemd, Windows Services)
|
||||
- ✅ Web dashboard with full agent management
|
||||
- ✅ Docker integration for container image updates
|
||||
|
||||
# Setup server
|
||||
cd aggregator-server && sudo ./redflag-server --setup
|
||||
**Known Issues:**
|
||||
- Windows Winget detection needs debugging
|
||||
- Some Windows Updates may reappear after installation (known Windows Update quirk)
|
||||
|
||||
# Run migrations
|
||||
./redflag-server --migrate
|
||||
**Planned Features:**
|
||||
- Proxmox VM/container integration
|
||||
- Agent auto-update system
|
||||
- WebSocket real-time updates
|
||||
- Mobile-responsive dashboard improvements
|
||||
|
||||
# Start server
|
||||
./redflag-server
|
||||
```
|
||||
|
||||
### 2. Agent Deployment (Linux)
|
||||
```bash
|
||||
# Option 1: One-liner with registration token
|
||||
sudo bash -c 'curl -sfL https://redflag.wiuf.net/install | bash -s -- rf-tok-abc123'
|
||||
|
||||
# Option 2: Manual installation
|
||||
sudo ./install.sh --server https://redflag.wiuf.net:8080 --token rf-tok-abc123
|
||||
|
||||
# Option 3: Advanced configuration with proxy
|
||||
sudo ./redflag-agent --server https://redflag.wiuf.net:8080 \
|
||||
--token rf-tok-abc123 \
|
||||
--proxy-http http://proxy.company.com:8080 \
|
||||
--organization "my-homelab" \
|
||||
--tags "production,webserver"
|
||||
```
|
||||
|
||||
### 3. Windows Agent Deployment
|
||||
```powershell
|
||||
# PowerShell one-liner
|
||||
iwr https://redflag.wiuf.net/install.ps1 | iex -Arguments '--server https://redflag.wiuf.net:8080 --token rf-tok-abc123'
|
||||
|
||||
# Or manual download and install
|
||||
.\redflag-agent.exe --server https://redflag.wiuf.net:8080 --token rf-tok-abc123
|
||||
```
|
||||
|
||||
## Agent Configuration Options
|
||||
|
||||
### CLI Flags (Highest Priority)
|
||||
```bash
|
||||
./redflag-agent --server https://redflag.wiuf.net \
|
||||
--token rf-tok-abc123 \
|
||||
--proxy-http http://proxy.company.com:8080 \
|
||||
--proxy-https https://proxy.company.com:8080 \
|
||||
--log-level debug \
|
||||
--organization "my-homelab" \
|
||||
--tags "production,webserver" \
|
||||
--name "redflag-server-01" \
|
||||
--insecure-tls
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
```bash
|
||||
export REDFLAG_SERVER_URL="https://redflag.wiuf.net"
|
||||
export REDFLAG_REGISTRATION_TOKEN="rf-tok-abc123"
|
||||
export REDFLAG_HTTP_PROXY="http://proxy.company.com:8080"
|
||||
export REDFLAG_HTTPS_PROXY="https://proxy.company.com:8080"
|
||||
export REDFLAG_NO_PROXY="localhost,127.0.0.1"
|
||||
export REDFLAG_LOG_LEVEL="info"
|
||||
export REDFLAG_ORGANIZATION="my-homelab"
|
||||
```
|
||||
|
||||
### Configuration File
|
||||
```json
|
||||
{
|
||||
"server_url": "https://redflag.wiuf.net",
|
||||
"registration_token": "rf-tok-abc123",
|
||||
"proxy": {
|
||||
"enabled": true,
|
||||
"http": "http://proxy.company.com:8080",
|
||||
"https": "https://proxy.company.com:8080",
|
||||
"no_proxy": "localhost,127.0.0.1"
|
||||
},
|
||||
"network": {
|
||||
"timeout": "30s",
|
||||
"retry_count": 3,
|
||||
"retry_delay": "5s"
|
||||
},
|
||||
"logging": {
|
||||
"level": "info",
|
||||
"max_size": 100,
|
||||
"max_backups": 3
|
||||
},
|
||||
"tags": ["production", "webserver"],
|
||||
"organization": "my-homelab",
|
||||
"display_name": "redflag-server-01"
|
||||
}
|
||||
```
|
||||
|
||||
## Web Dashboard Features
|
||||
|
||||
### Agent Management
|
||||
- Real-time Status: Online/offline with heartbeat indicators
|
||||
- System Information: CPU, memory, disk usage, OS details
|
||||
- Version Tracking: Agent versions and update availability
|
||||
- Metadata Management: Tags, organizations, display names
|
||||
- Bulk Operations: Multi-agent scanning and updates
|
||||
|
||||
### Update Management
|
||||
- Severity Classification: Critical, high, medium, low priority updates
|
||||
- Approval Workflow: Controlled update deployment with dependencies
|
||||
- Dependency Resolution: Safe installation with conflict checking
|
||||
- Batch Operations: Approve/install multiple updates
|
||||
- Audit Trail: Complete history of all operations
|
||||
|
||||
### Settings & Administration
|
||||
- Registration Tokens: Generate and manage secure enrollment tokens
|
||||
- Rate Limiting: User-adjustable API security settings
|
||||
- Authentication: Secure login with session management
|
||||
- Audit Logging: Comprehensive activity tracking
|
||||
- Server Configuration: Admin settings and system controls
|
||||
|
||||
## API Reference
|
||||
|
||||
### Registration Token Management
|
||||
```bash
|
||||
# Generate registration token
|
||||
curl -X POST https://redflag.wiuf.net/api/v1/admin/registration-tokens \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-d '{"label": "Production Servers", "expires_in": "24h"}'
|
||||
|
||||
# List tokens
|
||||
curl -X GET https://redflag.wiuf.net/api/v1/admin/registration-tokens \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN"
|
||||
|
||||
# Revoke token
|
||||
curl -X DELETE https://redflag.wiuf.net/api/v1/admin/registration-tokens/rf-tok-abc123 \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN"
|
||||
```
|
||||
|
||||
### Rate Limit Management
|
||||
```bash
|
||||
# View current settings
|
||||
curl -X GET https://redflag.wiuf.net/api/v1/admin/rate-limits \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN"
|
||||
|
||||
# Update settings
|
||||
curl -X PUT https://redflag.wiuf.net/api/v1/admin/rate-limits \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-d '{
|
||||
"agent_registration": {"requests": 10, "window": "1m", "enabled": true},
|
||||
"admin_operations": {"requests": 200, "window": "1m", "enabled": true}
|
||||
}'
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
### Authentication & Authorization
|
||||
- Registration Tokens: One-time use tokens prevent unauthorized agent enrollment
|
||||
- Refresh Token Authentication: 90-day sliding window with 24h access tokens
|
||||
- SHA-256 token hashing for secure storage
|
||||
- Admin authentication for server access and management
|
||||
|
||||
### Network Security
|
||||
- Rate Limiting: Configurable API protection with sensible defaults
|
||||
- TLS Support: Certificate validation and client certificate support
|
||||
- Pull-based Model: Agents poll server (firewall-friendly)
|
||||
- HTTPS Required: Production deployments must use TLS
|
||||
|
||||
### System Hardening
|
||||
- Minimal Privilege Execution: Agents run with least required privileges
|
||||
- Command Validation: Whitelisted commands only
|
||||
- Secure Defaults: Hardened configurations out of the box
|
||||
- Security Hardening: Minimal privilege execution and sudoers management
|
||||
|
||||
### Audit & Monitoring
|
||||
- Audit Trails: Complete logging of all activities
|
||||
- Token Renewal: `/renew` endpoint prevents daily re-registration
|
||||
- Activity Tracking: Comprehensive monitoring and alerting
|
||||
- Access Logs: Full audit trail of user and agent actions
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
version: '3.8'
|
||||
services:
|
||||
redflag-server:
|
||||
build: ./aggregator-server
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
- REDFLAG_SERVER_HOST=0.0.0.0
|
||||
- REDFLAG_SERVER_PORT=8080
|
||||
- REDFLAG_DB_HOST=postgres
|
||||
- REDFLAG_DB_PORT=5432
|
||||
- REDFLAG_DB_NAME=redflag
|
||||
- REDFLAG_DB_USER=redflag
|
||||
- REDFLAG_DB_PASSWORD=secure-password
|
||||
depends_on:
|
||||
- postgres
|
||||
volumes:
|
||||
- ./redflag-data:/etc/redflag
|
||||
- ./logs:/app/logs
|
||||
|
||||
postgres:
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_DB: redflag
|
||||
POSTGRES_USER: redflag
|
||||
POSTGRES_PASSWORD: secure-password
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
RedFlag/
|
||||
├── aggregator-server/ # Go server backend
|
||||
│ ├── cmd/server/ # Main server entry point
|
||||
│ ├── internal/
|
||||
│ │ ├── api/ # REST API handlers and middleware
|
||||
│ │ │ └── handlers/ # API endpoint implementations
|
||||
│ │ ├── database/ # Database layer with migrations
|
||||
│ │ │ ├── migrations/ # Database schema migrations
|
||||
│ │ │ └── queries/ # Database query functions
|
||||
│ │ ├── models/ # Data models and structs
|
||||
│ │ ├── services/ # Business logic services
|
||||
│ │ └── config/ # Configuration management
|
||||
│ └── redflag-server # Server binary
|
||||
|
||||
├── aggregator-agent/ # Cross-platform Go agent
|
||||
│ ├── cmd/agent/ # Agent main entry point
|
||||
│ ├── internal/
|
||||
│ │ ├── client/ # HTTP client with token renewal
|
||||
│ │ ├── config/ # Enhanced configuration system
|
||||
│ │ ├── scanner/ # Update scanners for each platform
|
||||
│ │ ├── installer/ # Package installers
|
||||
│ │ └── system/ # System information collection
|
||||
│ ├── install.sh # Linux installation script
|
||||
│ └── redflag-agent # Agent binary
|
||||
|
||||
├── aggregator-web/ # React dashboard
|
||||
├── docker-compose.yml # Development environment
|
||||
├── Makefile # Common tasks
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## What This Is
|
||||
|
||||
A self-hosted, cross-platform update management platform built with:
|
||||
|
||||
- Go server backend + PostgreSQL
|
||||
- React web dashboard with TypeScript
|
||||
- Cross-platform agents (Linux APT/DNF/Docker, Windows Updates/Winget)
|
||||
- Local CLI tools for agent management
|
||||
- Update installation system with dependency management
|
||||
- Refresh token authentication for stable agent identity
|
||||
|
||||
## What This Isn't
|
||||
|
||||
- Not ready for public use
|
||||
- Not documented for external users
|
||||
- Not supported or maintained for others
|
||||
- Not stable (active development)
|
||||
|
||||
## Current Capabilities
|
||||
|
||||
### Working Features
|
||||
- Server backend with REST API
|
||||
- Cross-platform agent registration and check-in
|
||||
- Update discovery for APT, DNF, Docker images, Windows Updates, and Winget packages
|
||||
- Update approval workflow with dependency confirmation
|
||||
- Web dashboard with agent management and real-time status
|
||||
- Local CLI tools (--scan, --status, --list-updates, --export, --export=json/csv)
|
||||
- Update installation system with dry-run dependency checking
|
||||
- Beautiful terminal output with colors and severity indicators
|
||||
- Local cache system for offline viewing of scan results
|
||||
- Refresh token authentication for stable agent identity
|
||||
- Event-sourced database architecture for scalability
|
||||
|
||||
### Known Limitations
|
||||
- No real-time WebSocket updates
|
||||
- Proxmox integration is not implemented in this version (planned for future release)
|
||||
- Authentication system works but needs security hardening
|
||||
|
||||
## Screenshots
|
||||
|
||||
| Overview | Updates Management | Agent List |
|
||||
|----------|-------------------|------------|
|
||||
|  |  |  |
|
||||
| System overview with metrics | Update approval with dependency workflow | Cross-platform agent management |
|
||||
|
||||
| Linux Agent Details | Windows Agent Details |
|
||||
|-------------------|---------------------|
|
||||
|  |  |
|
||||
| Linux system specs and updates | Windows Updates and Winget support |
|
||||
|
||||
| History & Audit | Windows Agent History |
|
||||
|----------------|----------------------|
|
||||
|  |  |
|
||||
| Complete audit trail of activities | Windows agent activity timeline |
|
||||
|
||||
| Live Operations | Docker Management |
|
||||
|-----------------|------------------|
|
||||
|  |  |
|
||||
| Real-time operation tracking | Container image update management |
|
||||
|
||||
## For Developers
|
||||
|
||||
This repository contains:
|
||||
|
||||
- **Server backend code** (`aggregator-server/`)
|
||||
- **Agent code** (`aggregator-agent/`)
|
||||
- **Web dashboard** (`aggregator-web/`)
|
||||
- **Database migrations** and configuration
|
||||
|
||||
## Database Schema
|
||||
|
||||
Key Tables:
|
||||
- `agents` - Registered agents with system metadata and version tracking
|
||||
- `refresh_tokens` - Long-lived refresh tokens for stable agent identity
|
||||
- `update_events` - Immutable event storage for update discoveries
|
||||
- `current_package_state` - Optimized view of current update state
|
||||
- `agent_commands` - Command queue for agents (scan, install, dry-run)
|
||||
- `update_logs` - Execution logs with detailed results
|
||||
- `agent_tags` - Agent tagging/grouping
|
||||
|
||||
## Configuration
|
||||
|
||||
### Server (.env)
|
||||
```bash
|
||||
SERVER_PORT=8080
|
||||
DATABASE_URL=postgres://aggregator:aggregator@localhost:5432/aggregator?sslmode=disable
|
||||
JWT_SECRET=change-me-in-production
|
||||
CHECK_IN_INTERVAL=300 # seconds
|
||||
OFFLINE_THRESHOLD=600 # seconds
|
||||
```
|
||||
|
||||
### Agent (/etc/aggregator/config.json)
|
||||
Auto-generated on registration:
|
||||
```json
|
||||
{
|
||||
"server_url": "http://localhost:8080",
|
||||
"agent_id": "uuid",
|
||||
"token": "jwt-access-token",
|
||||
"refresh_token": "long-lived-refresh-token",
|
||||
"check_in_interval": 300
|
||||
}
|
||||
```
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
### Makefile Commands
|
||||
```bash
|
||||
make help # Show all commands
|
||||
make db-up # Start PostgreSQL
|
||||
make db-down # Stop PostgreSQL
|
||||
make server # Run server (with auto-reload)
|
||||
make agent # Run agent
|
||||
make build-server # Build server binary
|
||||
make build-agent # Build agent binary
|
||||
make test # Run tests
|
||||
make clean # Clean build artifacts
|
||||
# Start local development environment
|
||||
make db-up
|
||||
make server # Terminal 1
|
||||
make agent # Terminal 2
|
||||
make web # Terminal 3
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
```bash
|
||||
cd aggregator-server && go test ./...
|
||||
cd aggregator-agent && go test ./...
|
||||
```
|
||||
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for detailed build instructions.
|
||||
|
||||
## API Usage
|
||||
---
|
||||
|
||||
### List All Agents
|
||||
```bash
|
||||
curl http://localhost:8080/api/v1/agents
|
||||
```
|
||||
## Alpha Release Notice
|
||||
|
||||
### Trigger Update Scan
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/agents/{agent-id}/scan
|
||||
```
|
||||
This is alpha software built for homelabs and self-hosters. It's functional and actively used, but:
|
||||
|
||||
### List All Updates
|
||||
```bash
|
||||
# All updates
|
||||
curl http://localhost:8080/api/v1/updates
|
||||
- Expect occasional bugs
|
||||
- Backup your data
|
||||
- Security model is solid but not audited
|
||||
- Breaking changes may happen between versions
|
||||
- Documentation is a work in progress
|
||||
|
||||
# Filter by severity
|
||||
curl http://localhost:8080/api/v1/updates?severity=critical
|
||||
That said, it works well for its intended use case. Issues and feedback welcome!
|
||||
|
||||
# Filter by status
|
||||
curl http://localhost:8080/api/v1/updates?status=pending
|
||||
```
|
||||
|
||||
### Approve an Update
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/updates/{update-id}/approve
|
||||
```
|
||||
|
||||
### Token Renewal (Agent Authentication)
|
||||
```bash
|
||||
# Exchange refresh token for new access token
|
||||
curl -X POST http://localhost:8080/api/v1/agents/renew \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"agent_id": "uuid",
|
||||
"refresh_token": "long-lived-token"
|
||||
}'
|
||||
```
|
||||
|
||||
### Dependency Workflow
|
||||
```bash
|
||||
# Dry run to check dependencies (automatically triggered by install)
|
||||
curl -X POST http://localhost:8080/api/v1/updates/{update-id}/approve
|
||||
|
||||
# Confirm dependencies and install
|
||||
curl -X POST http://localhost:8080/api/v1/updates/{update-id}/confirm-dependencies
|
||||
```
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see LICENSE file for details.
|
||||
MIT License - See [LICENSE](LICENSE) for details
|
||||
|
||||
This is private development software. Use at your own risk.
|
||||
**Third-Party Components:**
|
||||
- Windows Update integration based on [windowsupdate](https://github.com/ceshihao/windowsupdate) (Apache 2.0)
|
||||
|
||||
## Third-Party Licenses
|
||||
---
|
||||
|
||||
### Windows Update Package (Apache 2.0)
|
||||
This project includes a modified version of the `windowsupdate` package from https://github.com/ceshihao/windowsupdate
|
||||
## Project Goals
|
||||
|
||||
Copyright 2022 Zheng Dayu
|
||||
Licensed under the Apache License, Version 2.0
|
||||
Original package: https://github.com/ceshihao/windowsupdate
|
||||
RedFlag aims to be:
|
||||
- **Simple** - Deploy in 5 minutes, understand in 10
|
||||
- **Honest** - No enterprise marketing speak, just useful software
|
||||
- **Homelab-first** - Built for real use cases, not investor pitches
|
||||
- **Self-hosted** - Your data, your infrastructure
|
||||
|
||||
The package is included in `aggregator-agent/pkg/windowsupdate/` and has been modified for integration with RedFlag's update management system.
|
||||
If you're looking for an enterprise-grade solution with SLAs and support contracts, this isn't it. If you want to manage updates across your homelab without SSH-ing into every server, welcome aboard.
|
||||
|
||||
---
|
||||
|
||||
**Made with ☕ for homelabbers, by homelabbers**
|
||||
|
||||
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 93 KiB |
BIN
Screenshots/RedFlag Heartbeat System.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
Screenshots/RedFlag Linux Agent History Extended.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
Screenshots/RedFlag Registration Tokens.jpg
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
Screenshots/RedFlag Settings Page.jpg
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
Screenshots/RedFlag Windows Agent History Extended.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
@@ -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
@@ -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/
|
||||
@@ -16,12 +16,13 @@ import (
|
||||
"github.com/Fimeg/RedFlag/aggregator-agent/internal/display"
|
||||
"github.com/Fimeg/RedFlag/aggregator-agent/internal/installer"
|
||||
"github.com/Fimeg/RedFlag/aggregator-agent/internal/scanner"
|
||||
"github.com/Fimeg/RedFlag/aggregator-agent/internal/service"
|
||||
"github.com/Fimeg/RedFlag/aggregator-agent/internal/system"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
AgentVersion = "0.1.16" // Enhanced configuration system with proxy support and registration tokens
|
||||
AgentVersion = "0.1.17" // Fixed Linux disk detection to show all physical mount points (/, /home, etc.)
|
||||
)
|
||||
|
||||
// getConfigPath returns the platform-specific config path
|
||||
@@ -86,6 +87,13 @@ func main() {
|
||||
displayName := flag.String("name", "", "Display name for agent")
|
||||
insecureTLS := flag.Bool("insecure-tls", false, "Skip TLS certificate verification")
|
||||
exportFormat := flag.String("export", "", "Export format: json, csv")
|
||||
|
||||
// Windows service management commands
|
||||
installServiceCmd := flag.Bool("install-service", false, "Install as Windows service")
|
||||
removeServiceCmd := flag.Bool("remove-service", false, "Remove Windows service")
|
||||
startServiceCmd := flag.Bool("start-service", false, "Start Windows service")
|
||||
stopServiceCmd := flag.Bool("stop-service", false, "Stop Windows service")
|
||||
serviceStatusCmd := flag.Bool("service-status", false, "Show Windows service status")
|
||||
flag.Parse()
|
||||
|
||||
// Handle version command
|
||||
@@ -95,6 +103,48 @@ func main() {
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// Handle Windows service management commands (only on Windows)
|
||||
if runtime.GOOS == "windows" {
|
||||
if *installServiceCmd {
|
||||
if err := service.InstallService(); err != nil {
|
||||
log.Fatalf("Failed to install service: %v", err)
|
||||
}
|
||||
fmt.Println("RedFlag service installed successfully")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if *removeServiceCmd {
|
||||
if err := service.RemoveService(); err != nil {
|
||||
log.Fatalf("Failed to remove service: %v", err)
|
||||
}
|
||||
fmt.Println("RedFlag service removed successfully")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if *startServiceCmd {
|
||||
if err := service.StartService(); err != nil {
|
||||
log.Fatalf("Failed to start service: %v", err)
|
||||
}
|
||||
fmt.Println("RedFlag service started successfully")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if *stopServiceCmd {
|
||||
if err := service.StopService(); err != nil {
|
||||
log.Fatalf("Failed to stop service: %v", err)
|
||||
}
|
||||
fmt.Println("RedFlag service stopped successfully")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if *serviceStatusCmd {
|
||||
if err := service.ServiceStatus(); err != nil {
|
||||
log.Fatalf("Failed to get service status: %v", err)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse tags from comma-separated string
|
||||
var tags []string
|
||||
if *tagsFlag != "" {
|
||||
@@ -197,7 +247,16 @@ func main() {
|
||||
log.Fatal("Agent not registered. Run with -register flag first.")
|
||||
}
|
||||
|
||||
// Start agent service
|
||||
// Check if running as Windows service
|
||||
if runtime.GOOS == "windows" && service.IsService() {
|
||||
// Run as Windows service
|
||||
if err := service.RunService(cfg); err != nil {
|
||||
log.Fatal("Service failed:", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Start agent service (console mode)
|
||||
if err := runAgent(cfg); err != nil {
|
||||
log.Fatal("Agent failed:", err)
|
||||
}
|
||||
@@ -221,7 +280,8 @@ func registerAgent(cfg *config.Config, serverURL string) error {
|
||||
}
|
||||
}
|
||||
|
||||
apiClient := client.NewClient(serverURL, "")
|
||||
// Use registration token from config if available
|
||||
apiClient := client.NewClient(serverURL, cfg.RegistrationToken)
|
||||
|
||||
// Create metadata with system information
|
||||
metadata := map[string]string{
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
module github.com/Fimeg/RedFlag/aggregator-agent
|
||||
|
||||
go 1.21
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/docker/docker v27.4.1+incompatible
|
||||
github.com/go-ole/go-ole v1.3.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/scjalliance/comshim v0.0.0-20250111221056-b2ef9d8d7e0f
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -16,7 +18,6 @@ require (
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
@@ -24,7 +25,6 @@ require (
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/scjalliance/comshim v0.0.0-20250111221056-b2ef9d8d7e0f // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
|
||||
go.opentelemetry.io/otel v1.38.0 // indirect
|
||||
@@ -32,6 +32,6 @@ require (
|
||||
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/time v0.5.0 // indirect
|
||||
gotest.tools/v3 v3.5.2 // indirect
|
||||
)
|
||||
|
||||
@@ -105,8 +105,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
|
||||
@@ -56,6 +56,13 @@ install_binary() {
|
||||
chmod 755 "$AGENT_BINARY"
|
||||
chown root:root "$AGENT_BINARY"
|
||||
echo "✓ Agent binary installed"
|
||||
|
||||
# Set SELinux context for binary if SELinux is enabled
|
||||
if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce)" != "Disabled" ]; then
|
||||
echo "SELinux detected, setting file context for binary..."
|
||||
restorecon -v "$AGENT_BINARY" 2>/dev/null || true
|
||||
echo "✓ SELinux context set for binary"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to install sudoers configuration
|
||||
@@ -167,6 +174,13 @@ register_agent() {
|
||||
# Create config directory
|
||||
mkdir -p /etc/aggregator
|
||||
|
||||
# Set SELinux context for config directory if SELinux is enabled
|
||||
if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce)" != "Disabled" ]; then
|
||||
echo "Setting SELinux context for config directory..."
|
||||
restorecon -Rv /etc/aggregator 2>/dev/null || true
|
||||
echo "✓ SELinux context set for config directory"
|
||||
fi
|
||||
|
||||
# Register agent (run as regular binary, not as service)
|
||||
if "$AGENT_BINARY" -register -server "$server_url"; then
|
||||
echo "✓ Agent registered successfully"
|
||||
|
||||
@@ -51,6 +51,7 @@ type RegisterRequest struct {
|
||||
OSVersion string `json:"os_version"`
|
||||
OSArchitecture string `json:"os_architecture"`
|
||||
AgentVersion string `json:"agent_version"`
|
||||
RegistrationToken string `json:"registration_token,omitempty"` // Fallback method
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
|
||||
@@ -66,6 +67,12 @@ type RegisterResponse struct {
|
||||
func (c *Client) Register(req RegisterRequest) (*RegisterResponse, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/agents/register", c.baseURL)
|
||||
|
||||
// If we have a registration token, include it in the request
|
||||
// Registration tokens are longer than regular JWT tokens (usually 64 chars vs JWT ~400 chars)
|
||||
if c.token != "" && len(c.token) > 40 {
|
||||
req.RegistrationToken = c.token
|
||||
}
|
||||
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -77,6 +84,12 @@ func (c *Client) Register(req RegisterRequest) (*RegisterResponse, error) {
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Add Authorization header if we have a registration token (preferred method)
|
||||
// Registration tokens are longer than regular JWT tokens (usually 64 chars vs JWT ~400 chars)
|
||||
if c.token != "" && len(c.token) > 40 {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -349,6 +349,12 @@ func (c *Config) Save(configPath string) error {
|
||||
return fmt.Errorf("failed to marshal config: %w", err)
|
||||
}
|
||||
|
||||
// Create parent directory if it doesn't exist
|
||||
dir := filepath.Dir(configPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create config directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(configPath, data, 0600); err != nil {
|
||||
return fmt.Errorf("failed to write config: %w", err)
|
||||
}
|
||||
|
||||
52
aggregator-agent/internal/service/service_stub.go
Normal 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)
|
||||
}
|
||||
1329
aggregator-agent/internal/service/windows.go
Normal file
@@ -262,9 +262,42 @@ func getDiskInfo() ([]DiskInfo, error) {
|
||||
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 6 {
|
||||
mountpoint := fields[0]
|
||||
filesystem := fields[5]
|
||||
|
||||
// Filter out pseudo-filesystems and only show physical/important mounts
|
||||
// Skip tmpfs, devtmpfs, overlay, squashfs, etc.
|
||||
if strings.HasPrefix(filesystem, "tmpfs") ||
|
||||
strings.HasPrefix(filesystem, "devtmpfs") ||
|
||||
strings.HasPrefix(filesystem, "overlay") ||
|
||||
strings.HasPrefix(filesystem, "squashfs") ||
|
||||
strings.HasPrefix(filesystem, "udev") ||
|
||||
strings.HasPrefix(filesystem, "proc") ||
|
||||
strings.HasPrefix(filesystem, "sysfs") ||
|
||||
strings.HasPrefix(filesystem, "cgroup") ||
|
||||
strings.HasPrefix(filesystem, "devpts") ||
|
||||
strings.HasPrefix(filesystem, "securityfs") ||
|
||||
strings.HasPrefix(filesystem, "pstore") ||
|
||||
strings.HasPrefix(filesystem, "bpf") ||
|
||||
strings.HasPrefix(filesystem, "configfs") ||
|
||||
strings.HasPrefix(filesystem, "fusectl") ||
|
||||
strings.HasPrefix(filesystem, "hugetlbfs") ||
|
||||
strings.HasPrefix(filesystem, "mqueue") ||
|
||||
strings.HasPrefix(filesystem, "debugfs") ||
|
||||
strings.HasPrefix(filesystem, "tracefs") {
|
||||
continue // Skip virtual/pseudo filesystems
|
||||
}
|
||||
|
||||
// Skip container/snap mounts unless they're important
|
||||
if strings.Contains(mountpoint, "/snap/") ||
|
||||
strings.Contains(mountpoint, "/var/lib/docker") ||
|
||||
strings.Contains(mountpoint, "/run") {
|
||||
continue
|
||||
}
|
||||
|
||||
disk := DiskInfo{
|
||||
Mountpoint: fields[0],
|
||||
Filesystem: fields[5],
|
||||
Mountpoint: mountpoint,
|
||||
Filesystem: filesystem,
|
||||
}
|
||||
|
||||
// Parse sizes (df outputs in human readable format, we'll parse the numeric part)
|
||||
|
||||
@@ -14,19 +14,33 @@ import (
|
||||
func getWindowsInfo() string {
|
||||
// Try using wmic for detailed Windows version info
|
||||
if cmd, err := exec.LookPath("wmic"); err == nil {
|
||||
if data, err := exec.Command(cmd, "os", "get", "Caption,Version,BuildNumber,SKU").Output(); err == nil {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "Microsoft Windows") {
|
||||
// Clean up the output
|
||||
line = strings.TrimSpace(line)
|
||||
// Remove extra spaces
|
||||
for strings.Contains(line, " ") {
|
||||
line = strings.ReplaceAll(line, " ", " ")
|
||||
}
|
||||
return line
|
||||
// Get Caption (e.g., "Microsoft Windows 10 Pro")
|
||||
caption := ""
|
||||
if data, err := exec.Command(cmd, "os", "get", "Caption", "/value").Output(); err == nil {
|
||||
output := strings.TrimSpace(string(data))
|
||||
if strings.HasPrefix(output, "Caption=") {
|
||||
caption = strings.TrimPrefix(output, "Caption=")
|
||||
caption = strings.TrimSpace(caption)
|
||||
}
|
||||
}
|
||||
|
||||
// Get Version and Build Number
|
||||
version := ""
|
||||
if data, err := exec.Command(cmd, "os", "get", "Version", "/value").Output(); err == nil {
|
||||
output := strings.TrimSpace(string(data))
|
||||
if strings.HasPrefix(output, "Version=") {
|
||||
version = strings.TrimPrefix(output, "Version=")
|
||||
version = strings.TrimSpace(version)
|
||||
}
|
||||
}
|
||||
|
||||
// Combine caption and version for clean output
|
||||
if caption != "" && version != "" {
|
||||
return fmt.Sprintf("%s (Build %s)", caption, version)
|
||||
} else if caption != "" {
|
||||
return caption
|
||||
} else if version != "" {
|
||||
return fmt.Sprintf("Windows %s", version)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,31 +194,50 @@ func getWindowsDiskInfo() ([]DiskInfo, error) {
|
||||
var disks []DiskInfo
|
||||
|
||||
if cmd, err := exec.LookPath("wmic"); err == nil {
|
||||
// Get logical disk information
|
||||
if data, err := exec.Command(cmd, "logicaldisk", "get", "DeviceID,Size,FreeSpace,FileSystem").Output(); err == nil {
|
||||
// Get logical disk information - use /value format for reliable parsing
|
||||
if data, err := exec.Command(cmd, "logicaldisk", "get", "DeviceID,Size,FreeSpace,FileSystem", "/format:csv").Output(); err == nil {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) != "" && !strings.Contains(line, "DeviceID") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 4 {
|
||||
for i, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
// Skip header and empty lines
|
||||
if i == 0 || line == "" || !strings.Contains(line, ",") {
|
||||
continue
|
||||
}
|
||||
|
||||
// CSV format: Node,DeviceID,FileSystem,FreeSpace,Size
|
||||
fields := strings.Split(line, ",")
|
||||
if len(fields) >= 5 {
|
||||
deviceID := strings.TrimSpace(fields[1])
|
||||
filesystem := strings.TrimSpace(fields[2])
|
||||
freeSpaceStr := strings.TrimSpace(fields[3])
|
||||
sizeStr := strings.TrimSpace(fields[4])
|
||||
|
||||
// Skip if no size info (e.g., CD-ROM drives)
|
||||
if sizeStr == "" || freeSpaceStr == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
disk := DiskInfo{
|
||||
Mountpoint: strings.TrimSpace(fields[0]),
|
||||
Filesystem: strings.TrimSpace(fields[3]),
|
||||
Mountpoint: deviceID,
|
||||
Filesystem: filesystem,
|
||||
}
|
||||
|
||||
// Parse sizes (wmic outputs in bytes)
|
||||
if total, err := strconv.ParseUint(strings.TrimSpace(fields[1]), 10, 64); err == nil {
|
||||
if total, err := strconv.ParseUint(sizeStr, 10, 64); err == nil {
|
||||
disk.Total = total
|
||||
}
|
||||
if available, err := strconv.ParseUint(strings.TrimSpace(fields[2]), 10, 64); err == nil {
|
||||
if available, err := strconv.ParseUint(freeSpaceStr, 10, 64); err == nil {
|
||||
disk.Available = available
|
||||
}
|
||||
|
||||
// Calculate used space
|
||||
if disk.Total > 0 && disk.Available <= disk.Total {
|
||||
disk.Used = disk.Total - disk.Available
|
||||
if disk.Total > 0 {
|
||||
disk.UsedPercent = float64(disk.Used) / float64(disk.Total) * 100
|
||||
}
|
||||
|
||||
// Only add disks with valid size info
|
||||
if disk.Total > 0 {
|
||||
disks = append(disks, disk)
|
||||
}
|
||||
}
|
||||
@@ -238,36 +271,35 @@ func getWindowsProcessCount() (int, error) {
|
||||
func getWindowsUptime() (string, error) {
|
||||
// Try PowerShell first for more accurate uptime
|
||||
if cmd, err := exec.LookPath("powershell"); err == nil {
|
||||
// Get uptime in seconds for precise calculation
|
||||
if data, err := exec.Command(cmd, "-Command",
|
||||
"(Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime | Select-Object TotalDays").Output(); err == nil {
|
||||
// Parse the output to get days
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "TotalDays") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
if days, err := strconv.ParseFloat(fields[len(fields)-1], 64); err == nil {
|
||||
return formatUptimeFromDays(days), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
"(New-TimeSpan -Start (Get-CimInstance Win32_OperatingSystem).LastBootUpTime -End (Get-Date)).TotalSeconds").Output(); err == nil {
|
||||
secondsStr := strings.TrimSpace(string(data))
|
||||
if seconds, err := strconv.ParseFloat(secondsStr, 64); err == nil {
|
||||
return formatUptimeFromSeconds(seconds), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to wmic
|
||||
// Fallback to wmic with manual parsing
|
||||
if cmd, err := exec.LookPath("wmic"); err == nil {
|
||||
if data, err := exec.Command(cmd, "os", "get", "LastBootUpTime").Output(); err == nil {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) != "" && !strings.Contains(line, "LastBootUpTime") {
|
||||
// Parse WMI datetime format: 20231201123045.123456-300
|
||||
wmiTime := strings.TrimSpace(line)
|
||||
if data, err := exec.Command(cmd, "os", "get", "LastBootUpTime", "/value").Output(); err == nil {
|
||||
output := strings.TrimSpace(string(data))
|
||||
if strings.HasPrefix(output, "LastBootUpTime=") {
|
||||
wmiTime := strings.TrimPrefix(output, "LastBootUpTime=")
|
||||
wmiTime = strings.TrimSpace(wmiTime)
|
||||
// Parse WMI datetime format: 20251025123045.123456-300
|
||||
if len(wmiTime) >= 14 {
|
||||
// Extract just the date part for basic calculation
|
||||
// This is a simplified approach - in production you'd want proper datetime parsing
|
||||
return fmt.Sprintf("Since %s", wmiTime[:8]), nil
|
||||
}
|
||||
// Extract date/time components: YYYYMMDDHHmmss
|
||||
year := wmiTime[0:4]
|
||||
month := wmiTime[4:6]
|
||||
day := wmiTime[6:8]
|
||||
hour := wmiTime[8:10]
|
||||
minute := wmiTime[10:12]
|
||||
second := wmiTime[12:14]
|
||||
|
||||
bootTimeStr := fmt.Sprintf("%s-%s-%s %s:%s:%s", year, month, day, hour, minute, second)
|
||||
return fmt.Sprintf("Since %s", bootTimeStr), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,6 +308,27 @@ func getWindowsUptime() (string, error) {
|
||||
return "Unknown", nil
|
||||
}
|
||||
|
||||
// formatUptimeFromSeconds formats uptime from seconds into human readable format
|
||||
func formatUptimeFromSeconds(seconds float64) string {
|
||||
days := int(seconds / 86400)
|
||||
hours := int((seconds - float64(days*86400)) / 3600)
|
||||
minutes := int((seconds - float64(days*86400) - float64(hours*3600)) / 60)
|
||||
|
||||
if days > 0 {
|
||||
if hours > 0 {
|
||||
return fmt.Sprintf("%d days, %d hours", days, hours)
|
||||
}
|
||||
return fmt.Sprintf("%d days", days)
|
||||
} else if hours > 0 {
|
||||
if minutes > 0 {
|
||||
return fmt.Sprintf("%d hours, %d minutes", hours, minutes)
|
||||
}
|
||||
return fmt.Sprintf("%d hours", hours)
|
||||
} else {
|
||||
return fmt.Sprintf("%d minutes", minutes)
|
||||
}
|
||||
}
|
||||
|
||||
// formatUptimeFromDays formats uptime from days into human readable format
|
||||
func formatUptimeFromDays(days float64) string {
|
||||
if days < 1 {
|
||||
|
||||
@@ -1,18 +1,44 @@
|
||||
FROM golang:1.23-alpine AS builder
|
||||
# Stage 1: Build server binary
|
||||
FROM golang:1.23-alpine AS server-builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
COPY aggregator-server/go.mod aggregator-server/go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
COPY aggregator-server/ .
|
||||
RUN CGO_ENABLED=0 go build -o redflag-server cmd/server/main.go
|
||||
|
||||
# Stage 2: Build agent binaries for all platforms
|
||||
FROM golang:1.23-alpine AS agent-builder
|
||||
|
||||
WORKDIR /build
|
||||
# Copy agent source code
|
||||
COPY aggregator-agent/ ./
|
||||
|
||||
# Build for Linux amd64
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o binaries/linux-amd64/redflag-agent cmd/agent/main.go
|
||||
|
||||
# Build for Linux arm64
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o binaries/linux-arm64/redflag-agent cmd/agent/main.go
|
||||
|
||||
# Build for Windows amd64
|
||||
RUN CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o binaries/windows-amd64/redflag-agent.exe cmd/agent/main.go
|
||||
|
||||
# Build for Windows arm64
|
||||
RUN CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -o binaries/windows-arm64/redflag-agent.exe cmd/agent/main.go
|
||||
|
||||
# Stage 3: Final image with server and all agent binaries
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/redflag-server .
|
||||
# Copy server binary
|
||||
COPY --from=server-builder /app/redflag-server .
|
||||
COPY --from=server-builder /app/internal/database ./internal/database
|
||||
|
||||
# Copy all agent binaries
|
||||
COPY --from=agent-builder /build/binaries ./binaries
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
|
||||
@@ -35,15 +35,10 @@ func startWelcomeModeServer() {
|
||||
router.GET("/", setupHandler.ShowSetupPage)
|
||||
|
||||
// Setup endpoint for web configuration
|
||||
router.POST("/api/v1/setup", setupHandler.ConfigureServer)
|
||||
router.POST("/api/setup/configure", setupHandler.ConfigureServer)
|
||||
|
||||
// Setup endpoint for web configuration (future)
|
||||
router.GET("/setup", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{
|
||||
"message": "Web setup coming soon",
|
||||
"instructions": "Use: docker-compose exec server ./redflag-server --setup",
|
||||
})
|
||||
})
|
||||
// Setup endpoint for web configuration
|
||||
router.GET("/setup", setupHandler.ShowSetupPage)
|
||||
|
||||
log.Printf("Welcome mode server started on :8080")
|
||||
log.Printf("Waiting for configuration...")
|
||||
@@ -127,6 +122,14 @@ func main() {
|
||||
commandQueries := queries.NewCommandQueries(db.DB)
|
||||
refreshTokenQueries := queries.NewRefreshTokenQueries(db.DB)
|
||||
registrationTokenQueries := queries.NewRegistrationTokenQueries(db.DB)
|
||||
userQueries := queries.NewUserQueries(db.DB)
|
||||
|
||||
// Ensure admin user exists
|
||||
if err := userQueries.EnsureAdminUser(cfg.Admin.Username, cfg.Admin.Username+"@redflag.local", cfg.Admin.Password); err != nil {
|
||||
fmt.Printf("Warning: Failed to create admin user: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("✅ Admin user ensured")
|
||||
}
|
||||
|
||||
// Initialize services
|
||||
timezoneService := services.NewTimezoneService(cfg)
|
||||
@@ -136,15 +139,15 @@ func main() {
|
||||
rateLimiter := middleware.NewRateLimiter()
|
||||
|
||||
// Initialize handlers
|
||||
agentHandler := handlers.NewAgentHandler(agentQueries, commandQueries, refreshTokenQueries, cfg.CheckInInterval, cfg.LatestAgentVersion)
|
||||
agentHandler := handlers.NewAgentHandler(agentQueries, commandQueries, refreshTokenQueries, registrationTokenQueries, cfg.CheckInInterval, cfg.LatestAgentVersion)
|
||||
updateHandler := handlers.NewUpdateHandler(updateQueries, agentQueries, commandQueries, agentHandler)
|
||||
authHandler := handlers.NewAuthHandler(cfg.Admin.JWTSecret)
|
||||
authHandler := handlers.NewAuthHandler(cfg.Admin.JWTSecret, userQueries)
|
||||
statsHandler := handlers.NewStatsHandler(agentQueries, updateQueries)
|
||||
settingsHandler := handlers.NewSettingsHandler(timezoneService)
|
||||
dockerHandler := handlers.NewDockerHandler(updateQueries, agentQueries, commandQueries)
|
||||
registrationTokenHandler := handlers.NewRegistrationTokenHandler(registrationTokenQueries, agentQueries, cfg)
|
||||
rateLimitHandler := handlers.NewRateLimitHandler(rateLimiter)
|
||||
downloadHandler := handlers.NewDownloadHandler(filepath.Join(".", "redflag-agent"))
|
||||
downloadHandler := handlers.NewDownloadHandler(filepath.Join("/app"), cfg)
|
||||
|
||||
// Setup router
|
||||
router := gin.Default()
|
||||
@@ -169,6 +172,10 @@ func main() {
|
||||
api.POST("/agents/register", rateLimiter.RateLimit("agent_registration", middleware.KeyByIP), agentHandler.RegisterAgent)
|
||||
api.POST("/agents/renew", rateLimiter.RateLimit("public_access", middleware.KeyByIP), agentHandler.RenewToken)
|
||||
|
||||
// Public download routes (no authentication - agents need these!)
|
||||
api.GET("/downloads/:platform", rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.DownloadAgent)
|
||||
api.GET("/install/:platform", rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.InstallScript)
|
||||
|
||||
// Protected agent routes
|
||||
agents := api.Group("/agents")
|
||||
agents.Use(middleware.AuthMiddleware())
|
||||
@@ -225,10 +232,6 @@ func main() {
|
||||
dashboard.POST("/docker/containers/:container_id/images/:image_id/reject", dockerHandler.RejectUpdate)
|
||||
dashboard.POST("/docker/containers/:container_id/images/:image_id/install", dockerHandler.InstallUpdate)
|
||||
|
||||
// Download routes (authenticated)
|
||||
dashboard.GET("/downloads/:platform", downloadHandler.DownloadAgent)
|
||||
dashboard.GET("/install/:platform", downloadHandler.InstallScript)
|
||||
|
||||
// Admin/Registration Token routes (for agent enrollment management)
|
||||
admin := dashboard.Group("/admin")
|
||||
{
|
||||
@@ -236,6 +239,7 @@ func main() {
|
||||
admin.GET("/registration-tokens", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.ListRegistrationTokens)
|
||||
admin.GET("/registration-tokens/active", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.GetActiveRegistrationTokens)
|
||||
admin.DELETE("/registration-tokens/:token", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.RevokeRegistrationToken)
|
||||
admin.DELETE("/registration-tokens/delete/:id", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.DeleteRegistrationToken)
|
||||
admin.POST("/registration-tokens/cleanup", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.CleanupExpiredTokens)
|
||||
admin.GET("/registration-tokens/stats", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.GetTokenStats)
|
||||
admin.GET("/registration-tokens/validate", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.ValidateRegistrationToken)
|
||||
|
||||
@@ -18,15 +18,17 @@ type AgentHandler struct {
|
||||
agentQueries *queries.AgentQueries
|
||||
commandQueries *queries.CommandQueries
|
||||
refreshTokenQueries *queries.RefreshTokenQueries
|
||||
registrationTokenQueries *queries.RegistrationTokenQueries
|
||||
checkInInterval int
|
||||
latestAgentVersion string
|
||||
}
|
||||
|
||||
func NewAgentHandler(aq *queries.AgentQueries, cq *queries.CommandQueries, rtq *queries.RefreshTokenQueries, checkInInterval int, latestAgentVersion string) *AgentHandler {
|
||||
func NewAgentHandler(aq *queries.AgentQueries, cq *queries.CommandQueries, rtq *queries.RefreshTokenQueries, regTokenQueries *queries.RegistrationTokenQueries, checkInInterval int, latestAgentVersion string) *AgentHandler {
|
||||
return &AgentHandler{
|
||||
agentQueries: aq,
|
||||
commandQueries: cq,
|
||||
refreshTokenQueries: rtq,
|
||||
registrationTokenQueries: regTokenQueries,
|
||||
checkInInterval: checkInInterval,
|
||||
latestAgentVersion: latestAgentVersion,
|
||||
}
|
||||
@@ -40,6 +42,35 @@ func (h *AgentHandler) RegisterAgent(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate registration token (critical security check)
|
||||
// Extract token from Authorization header or request body
|
||||
var registrationToken string
|
||||
|
||||
// Try Authorization header first (Bearer token)
|
||||
if authHeader := c.GetHeader("Authorization"); authHeader != "" {
|
||||
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
|
||||
registrationToken = authHeader[7:]
|
||||
}
|
||||
}
|
||||
|
||||
// If not in header, try request body (fallback)
|
||||
if registrationToken == "" && req.RegistrationToken != "" {
|
||||
registrationToken = req.RegistrationToken
|
||||
}
|
||||
|
||||
// Reject if no registration token provided
|
||||
if registrationToken == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "registration token required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate the registration token
|
||||
tokenInfo, err := h.registrationTokenQueries.ValidateRegistrationToken(registrationToken)
|
||||
if err != nil || tokenInfo == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired registration token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create new agent
|
||||
agent := &models.Agent{
|
||||
ID: uuid.New(),
|
||||
@@ -66,6 +97,17 @@ func (h *AgentHandler) RegisterAgent(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Mark registration token as used (CRITICAL: must succeed or delete agent)
|
||||
if err := h.registrationTokenQueries.MarkTokenUsed(registrationToken, agent.ID); err != nil {
|
||||
// Token marking failed - rollback agent creation to prevent token reuse
|
||||
log.Printf("ERROR: Failed to mark registration token as used: %v - rolling back agent creation", err)
|
||||
if deleteErr := h.agentQueries.DeleteAgent(agent.ID); deleteErr != nil {
|
||||
log.Printf("ERROR: Failed to delete agent during rollback: %v", deleteErr)
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "registration token could not be consumed - token may be expired, revoked, or all seats may be used"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate JWT access token (short-lived: 24 hours)
|
||||
token, err := middleware.GenerateAgentToken(agent.ID)
|
||||
if err != nil {
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/aggregator-server/internal/database/queries"
|
||||
"github.com/Fimeg/RedFlag/aggregator-server/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
@@ -13,28 +15,34 @@ import (
|
||||
// AuthHandler handles authentication for the web dashboard
|
||||
type AuthHandler struct {
|
||||
jwtSecret string
|
||||
userQueries *queries.UserQueries
|
||||
}
|
||||
|
||||
// NewAuthHandler creates a new auth handler
|
||||
func NewAuthHandler(jwtSecret string) *AuthHandler {
|
||||
func NewAuthHandler(jwtSecret string, userQueries *queries.UserQueries) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
jwtSecret: jwtSecret,
|
||||
userQueries: userQueries,
|
||||
}
|
||||
}
|
||||
|
||||
// LoginRequest represents a login request
|
||||
type LoginRequest struct {
|
||||
Token string `json:"token" binding:"required"`
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
// LoginResponse represents a login response
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
User *models.User `json:"user"`
|
||||
}
|
||||
|
||||
// UserClaims represents JWT claims for web dashboard users
|
||||
type UserClaims struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
@@ -46,16 +54,18 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// For development, accept any non-empty token
|
||||
// In production, implement proper authentication
|
||||
if req.Token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
// Validate credentials against database
|
||||
user, err := h.userQueries.VerifyCredentials(req.Username, req.Password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create JWT token for web dashboard
|
||||
claims := UserClaims{
|
||||
UserID: uuid.New(), // Generate a user ID for this session
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
@@ -69,7 +79,10 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, LoginResponse{Token: tokenString})
|
||||
c.JSON(http.StatusOK, LoginResponse{
|
||||
Token: tokenString,
|
||||
User: user,
|
||||
})
|
||||
}
|
||||
|
||||
// VerifyToken handles token verification
|
||||
|
||||
@@ -1,49 +1,101 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Fimeg/RedFlag/aggregator-server/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DownloadHandler handles agent binary downloads
|
||||
type DownloadHandler struct {
|
||||
agentDir string
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
func NewDownloadHandler(agentDir string) *DownloadHandler {
|
||||
func NewDownloadHandler(agentDir string, cfg *config.Config) *DownloadHandler {
|
||||
return &DownloadHandler{
|
||||
agentDir: agentDir,
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// getServerURL determines the server URL with proper protocol detection
|
||||
func (h *DownloadHandler) getServerURL(c *gin.Context) string {
|
||||
// Priority 1: Use configured public URL if set
|
||||
if h.config.Server.PublicURL != "" {
|
||||
return h.config.Server.PublicURL
|
||||
}
|
||||
|
||||
// Priority 2: Detect from request with TLS/proxy awareness
|
||||
scheme := "http"
|
||||
|
||||
// Check if TLS is enabled in config
|
||||
if h.config.Server.TLS.Enabled {
|
||||
scheme = "https"
|
||||
}
|
||||
|
||||
// Check if request came through HTTPS (direct or via proxy)
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
|
||||
// Check X-Forwarded-Proto for reverse proxy setups
|
||||
if forwardedProto := c.GetHeader("X-Forwarded-Proto"); forwardedProto == "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
|
||||
// Use the Host header exactly as received (includes port if present)
|
||||
host := c.GetHeader("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = c.Request.Host
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s://%s", scheme, host)
|
||||
}
|
||||
|
||||
// DownloadAgent serves agent binaries for different platforms
|
||||
func (h *DownloadHandler) DownloadAgent(c *gin.Context) {
|
||||
platform := c.Param("platform")
|
||||
|
||||
// Validate platform to prevent directory traversal
|
||||
// Validate platform to prevent directory traversal (removed darwin - no macOS support)
|
||||
validPlatforms := map[string]bool{
|
||||
"linux-amd64": true,
|
||||
"linux-arm64": true,
|
||||
"windows-amd64": true,
|
||||
"windows-arm64": true,
|
||||
"darwin-amd64": true,
|
||||
"darwin-arm64": true,
|
||||
}
|
||||
|
||||
if !validPlatforms[platform] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid platform"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or unsupported platform"})
|
||||
return
|
||||
}
|
||||
|
||||
// Build filename based on platform
|
||||
filename := "redflag-agent"
|
||||
if strings.HasPrefix(platform, "windows") {
|
||||
filename += ".exe"
|
||||
}
|
||||
|
||||
agentPath := filepath.Join(h.agentDir, filename)
|
||||
// Serve from platform-specific directory: binaries/{platform}/redflag-agent
|
||||
agentPath := filepath.Join(h.agentDir, "binaries", platform, filename)
|
||||
|
||||
// Check if file exists
|
||||
if _, err := os.Stat(agentPath); os.IsNotExist(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Agent binary not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Handle both GET and HEAD requests
|
||||
if c.Request.Method == "HEAD" {
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
c.File(agentPath)
|
||||
}
|
||||
|
||||
@@ -51,75 +103,604 @@ func (h *DownloadHandler) DownloadAgent(c *gin.Context) {
|
||||
func (h *DownloadHandler) InstallScript(c *gin.Context) {
|
||||
platform := c.Param("platform")
|
||||
|
||||
// Validate platform
|
||||
// Validate platform (removed darwin - no macOS support)
|
||||
validPlatforms := map[string]bool{
|
||||
"linux": true,
|
||||
"darwin": true,
|
||||
"windows": true,
|
||||
}
|
||||
|
||||
if !validPlatforms[platform] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid platform"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or unsupported platform"})
|
||||
return
|
||||
}
|
||||
|
||||
scriptContent := h.generateInstallScript(platform, c.Request.Host)
|
||||
serverURL := h.getServerURL(c)
|
||||
scriptContent := h.generateInstallScript(platform, serverURL)
|
||||
c.Header("Content-Type", "text/plain")
|
||||
c.String(http.StatusOK, scriptContent)
|
||||
}
|
||||
|
||||
func (h *DownloadHandler) generateInstallScript(platform, serverHost string) string {
|
||||
baseURL := "http://" + serverHost
|
||||
|
||||
func (h *DownloadHandler) generateInstallScript(platform, baseURL string) string {
|
||||
switch platform {
|
||||
case "linux":
|
||||
return `#!/bin/bash
|
||||
set -e
|
||||
|
||||
# RedFlag Agent Installation Script
|
||||
# This script installs the RedFlag agent as a systemd service with proper security hardening
|
||||
|
||||
REDFLAG_SERVER="` + baseURL + `"
|
||||
AGENT_DIR="/usr/local/bin"
|
||||
SERVICE_NAME="redflag-agent"
|
||||
AGENT_USER="redflag-agent"
|
||||
AGENT_HOME="/var/lib/redflag-agent"
|
||||
AGENT_BINARY="/usr/local/bin/redflag-agent"
|
||||
SUDOERS_FILE="/etc/sudoers.d/redflag-agent"
|
||||
SERVICE_FILE="/etc/systemd/system/redflag-agent.service"
|
||||
CONFIG_DIR="/etc/aggregator"
|
||||
|
||||
echo "=== RedFlag Agent Installation ==="
|
||||
echo ""
|
||||
|
||||
# Check if running as root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Please run as root or with sudo"
|
||||
echo "ERROR: This script must be run as root (use sudo)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Installing RedFlag agent from ${REDFLAG_SERVER}..."
|
||||
# Detect architecture
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
x86_64)
|
||||
DOWNLOAD_ARCH="amd64"
|
||||
;;
|
||||
aarch64|arm64)
|
||||
DOWNLOAD_ARCH="arm64"
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: Unsupported architecture: $ARCH"
|
||||
echo "Supported: x86_64 (amd64), aarch64 (arm64)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Download agent
|
||||
curl -sfL "${REDFLAG_SERVER}/api/v1/downloads/linux-amd64" -o "${AGENT_DIR}/redflag-agent"
|
||||
chmod +x "${AGENT_DIR}/redflag-agent"
|
||||
echo "Detected architecture: $ARCH (using linux-$DOWNLOAD_ARCH)"
|
||||
echo ""
|
||||
|
||||
echo "Agent downloaded. Please visit ${REDFLAG_SERVER}/admin to get a registration token."
|
||||
echo "Then run: ${AGENT_DIR}/redflag-agent --server ${REDFLAG_SERVER} --token <YOUR_TOKEN>"`
|
||||
# Step 1: Create system user
|
||||
echo "Step 1: Creating system user..."
|
||||
if id "$AGENT_USER" &>/dev/null; then
|
||||
echo "✓ User $AGENT_USER already exists"
|
||||
else
|
||||
useradd -r -s /bin/false -d "$AGENT_HOME" -m "$AGENT_USER"
|
||||
echo "✓ User $AGENT_USER created"
|
||||
fi
|
||||
|
||||
case "darwin":
|
||||
return `#!/bin/bash
|
||||
set -e
|
||||
# Create home directory if it doesn't exist
|
||||
if [ ! -d "$AGENT_HOME" ]; then
|
||||
mkdir -p "$AGENT_HOME"
|
||||
chown "$AGENT_USER:$AGENT_USER" "$AGENT_HOME"
|
||||
echo "✓ Home directory created"
|
||||
fi
|
||||
|
||||
REDFLAG_SERVER="` + baseURL + `"
|
||||
AGENT_DIR="/usr/local/bin"
|
||||
# Stop existing service if running (to allow binary update)
|
||||
if systemctl is-active --quiet redflag-agent 2>/dev/null; then
|
||||
echo ""
|
||||
echo "Existing service detected - stopping to allow update..."
|
||||
systemctl stop redflag-agent
|
||||
sleep 2
|
||||
echo "✓ Service stopped"
|
||||
fi
|
||||
|
||||
echo "Installing RedFlag agent from ${REDFLAG_SERVER}..."
|
||||
# Step 2: Download agent binary
|
||||
echo ""
|
||||
echo "Step 2: Downloading agent binary..."
|
||||
echo "Downloading from ${REDFLAG_SERVER}/api/v1/downloads/linux-${DOWNLOAD_ARCH}..."
|
||||
|
||||
# Download agent
|
||||
curl -sfL "${REDFLAG_SERVER}/api/v1/downloads/darwin-amd64" -o "${AGENT_DIR}/redflag-agent"
|
||||
chmod +x "${AGENT_DIR}/redflag-agent"
|
||||
# Download to temporary file first (to avoid root permission issues)
|
||||
TEMP_FILE="/tmp/redflag-agent-${DOWNLOAD_ARCH}"
|
||||
echo "Downloading to temporary file: $TEMP_FILE"
|
||||
|
||||
echo "Agent downloaded. Please visit ${REDFLAG_SERVER}/admin to get a registration token."
|
||||
echo "Then run: ${AGENT_DIR}/redflag-agent --server ${REDFLAG_SERVER} --token <YOUR_TOKEN>"`
|
||||
# Try curl first (most reliable)
|
||||
if curl -sL "${REDFLAG_SERVER}/api/v1/downloads/linux-${DOWNLOAD_ARCH}" -o "$TEMP_FILE"; then
|
||||
echo "✓ Download successful, moving to final location"
|
||||
mv "$TEMP_FILE" "${AGENT_BINARY}"
|
||||
chmod 755 "${AGENT_BINARY}"
|
||||
chown root:root "${AGENT_BINARY}"
|
||||
echo "✓ Agent binary downloaded and installed"
|
||||
else
|
||||
echo "✗ Download with curl failed"
|
||||
# Fallback to wget if available
|
||||
if command -v wget >/dev/null 2>&1; then
|
||||
echo "Trying wget fallback..."
|
||||
if wget -q "${REDFLAG_SERVER}/api/v1/downloads/linux-${DOWNLOAD_ARCH}" -O "$TEMP_FILE"; then
|
||||
echo "✓ Download successful with wget, moving to final location"
|
||||
mv "$TEMP_FILE" "${AGENT_BINARY}"
|
||||
chmod 755 "${AGENT_BINARY}"
|
||||
chown root:root "${AGENT_BINARY}"
|
||||
echo "✓ Agent binary downloaded and installed (using wget fallback)"
|
||||
else
|
||||
echo "ERROR: Failed to download agent binary"
|
||||
echo "Both curl and wget failed"
|
||||
echo "Please ensure ${REDFLAG_SERVER} is accessible"
|
||||
# Clean up temp file if it exists
|
||||
rm -f "$TEMP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "ERROR: Failed to download agent binary"
|
||||
echo "curl failed and wget is not available"
|
||||
echo "Please ensure ${REDFLAG_SERVER} is accessible"
|
||||
# Clean up temp file if it exists
|
||||
rm -f "$TEMP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Clean up temp file if it still exists
|
||||
rm -f "$TEMP_FILE"
|
||||
|
||||
# Set SELinux context for binary if SELinux is enabled
|
||||
if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce)" != "Disabled" ]; then
|
||||
echo "SELinux detected, setting file context for binary..."
|
||||
restorecon -v "${AGENT_BINARY}" 2>/dev/null || true
|
||||
echo "✓ SELinux context set for binary"
|
||||
fi
|
||||
|
||||
# Step 3: Install sudoers configuration
|
||||
echo ""
|
||||
echo "Step 3: Installing sudoers configuration..."
|
||||
cat > "$SUDOERS_FILE" <<'SUDOERS_EOF'
|
||||
# RedFlag Agent minimal sudo permissions
|
||||
# This file grants the redflag-agent user limited sudo access for package management
|
||||
# Generated automatically during RedFlag agent installation
|
||||
|
||||
# APT package management commands (Debian/Ubuntu)
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get update
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get install -y *
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get upgrade -y *
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get install --dry-run --yes *
|
||||
|
||||
# DNF package management commands (RHEL/Fedora/Rocky/Alma)
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf makecache
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf install -y *
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf upgrade -y *
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf install --assumeno --downloadonly *
|
||||
|
||||
# Docker operations
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/docker pull *
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/docker image inspect *
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/docker manifest inspect *
|
||||
SUDOERS_EOF
|
||||
|
||||
chmod 440 "$SUDOERS_FILE"
|
||||
|
||||
# Validate sudoers file
|
||||
if visudo -c -f "$SUDOERS_FILE" &>/dev/null; then
|
||||
echo "✓ Sudoers configuration installed and validated"
|
||||
else
|
||||
echo "ERROR: Sudoers configuration is invalid"
|
||||
rm -f "$SUDOERS_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 4: Create configuration directory
|
||||
echo ""
|
||||
echo "Step 4: Creating configuration directory..."
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
chown "$AGENT_USER:$AGENT_USER" "$CONFIG_DIR"
|
||||
chmod 755 "$CONFIG_DIR"
|
||||
echo "✓ Configuration directory created"
|
||||
|
||||
# Set SELinux context for config directory if SELinux is enabled
|
||||
if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce)" != "Disabled" ]; then
|
||||
echo "Setting SELinux context for config directory..."
|
||||
restorecon -Rv "$CONFIG_DIR" 2>/dev/null || true
|
||||
echo "✓ SELinux context set for config directory"
|
||||
fi
|
||||
|
||||
# Step 5: Install systemd service
|
||||
echo ""
|
||||
echo "Step 5: Installing systemd service..."
|
||||
cat > "$SERVICE_FILE" <<SERVICE_EOF
|
||||
[Unit]
|
||||
Description=RedFlag Update Agent
|
||||
After=network.target
|
||||
Documentation=https://github.com/Fimeg/RedFlag
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$AGENT_USER
|
||||
Group=$AGENT_USER
|
||||
WorkingDirectory=$AGENT_HOME
|
||||
ExecStart=$AGENT_BINARY
|
||||
Restart=always
|
||||
RestartSec=30
|
||||
|
||||
# Security hardening
|
||||
# NoNewPrivileges=true - DISABLED: Prevents sudo from working, which agent needs for package management
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=$AGENT_HOME /var/log $CONFIG_DIR
|
||||
PrivateTmp=true
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=redflag-agent
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
SERVICE_EOF
|
||||
|
||||
chmod 644 "$SERVICE_FILE"
|
||||
echo "✓ Systemd service installed"
|
||||
|
||||
# Step 6: Register agent with server
|
||||
echo ""
|
||||
echo "Step 6: Agent registration"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# Check if token was provided as parameter (for one-liner support)
|
||||
if [ -n "$1" ]; then
|
||||
REGISTRATION_TOKEN="$1"
|
||||
echo "Using provided registration token"
|
||||
else
|
||||
# Check if stdin is a terminal (not being piped)
|
||||
if [ -t 0 ]; then
|
||||
echo "Registration token required to enroll this agent with the server."
|
||||
echo ""
|
||||
echo "To get a token:"
|
||||
echo " 1. Visit: ${REDFLAG_SERVER}/settings/tokens"
|
||||
echo " 2. Copy the active token from the list"
|
||||
echo ""
|
||||
echo "Enter registration token (or press Enter to skip):"
|
||||
read -p "> " REGISTRATION_TOKEN
|
||||
else
|
||||
echo ""
|
||||
echo "IMPORTANT: Registration token required!"
|
||||
echo ""
|
||||
echo "Since you're running this via pipe, you need to:"
|
||||
echo ""
|
||||
echo "Option 1 - One-liner with token:"
|
||||
echo " curl -sfL ${REDFLAG_SERVER}/api/v1/install/linux | sudo bash -s -- YOUR_TOKEN"
|
||||
echo ""
|
||||
echo "Option 2 - Download and run interactively:"
|
||||
echo " curl -sfL ${REDFLAG_SERVER}/api/v1/install/linux -o install.sh"
|
||||
echo " chmod +x install.sh"
|
||||
echo " sudo ./install.sh"
|
||||
echo ""
|
||||
echo "Skipping registration for now."
|
||||
echo "Please register manually after installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if agent is already registered
|
||||
if [ -f "$CONFIG_DIR/config.json" ]; then
|
||||
echo ""
|
||||
echo "[INFO] Agent already registered - configuration file exists"
|
||||
echo "[INFO] Skipping registration to preserve agent history"
|
||||
echo "[INFO] If you need to re-register, delete: $CONFIG_DIR/config.json"
|
||||
echo ""
|
||||
elif [ -n "$REGISTRATION_TOKEN" ]; then
|
||||
echo ""
|
||||
echo "Registering agent..."
|
||||
|
||||
# Create config file and register
|
||||
cat > "$CONFIG_DIR/config.json" <<EOF
|
||||
{
|
||||
"server_url": "${REDFLAG_SERVER}",
|
||||
"registration_token": "${REGISTRATION_TOKEN}"
|
||||
}
|
||||
EOF
|
||||
|
||||
# Set proper permissions
|
||||
chown "$AGENT_USER:$AGENT_USER" "$CONFIG_DIR/config.json"
|
||||
chmod 600 "$CONFIG_DIR/config.json"
|
||||
|
||||
# Run agent registration as the agent user with explicit server and token
|
||||
echo "Running: sudo -u $AGENT_USER ${AGENT_BINARY} --server ${REDFLAG_SERVER} --token $REGISTRATION_TOKEN --register"
|
||||
if sudo -u "$AGENT_USER" "${AGENT_BINARY}" --server "${REDFLAG_SERVER}" --token "$REGISTRATION_TOKEN" --register; then
|
||||
echo "✓ Agent registered successfully"
|
||||
|
||||
# Update config file with the new agent credentials
|
||||
if [ -f "$CONFIG_DIR/config.json" ]; then
|
||||
chown "$AGENT_USER:$AGENT_USER" "$CONFIG_DIR/config.json"
|
||||
chmod 600 "$CONFIG_DIR/config.json"
|
||||
echo "✓ Configuration file updated and secured"
|
||||
fi
|
||||
else
|
||||
echo "ERROR: Agent registration failed"
|
||||
echo "Please check the token and server URL, then try again"
|
||||
echo ""
|
||||
echo "To retry manually:"
|
||||
echo " sudo -u $AGENT_USER ${AGENT_BINARY} --server ${REDFLAG_SERVER} --token $REGISTRATION_TOKEN --register"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
echo "Skipping registration. You'll need to register manually before starting the service."
|
||||
echo ""
|
||||
echo "To register later:"
|
||||
echo " 1. Visit ${REDFLAG_SERVER}/settings/tokens"
|
||||
echo " 2. Copy a registration token"
|
||||
echo " 3. Run: sudo -u $AGENT_USER ${AGENT_BINARY} --server ${REDFLAG_SERVER} --token YOUR_TOKEN"
|
||||
echo ""
|
||||
echo "Installation will continue, but the service will not start until registered."
|
||||
fi
|
||||
|
||||
# Step 7: Enable and start service
|
||||
echo ""
|
||||
echo "Step 7: Enabling and starting service..."
|
||||
systemctl daemon-reload
|
||||
|
||||
# Check if agent is registered
|
||||
if [ -f "$CONFIG_DIR/config.json" ]; then
|
||||
systemctl enable redflag-agent
|
||||
systemctl restart redflag-agent
|
||||
|
||||
# Wait for service to start
|
||||
sleep 2
|
||||
|
||||
if systemctl is-active --quiet redflag-agent; then
|
||||
echo "✓ Service started successfully"
|
||||
else
|
||||
echo "⚠ Service failed to start. Check logs:"
|
||||
echo " sudo journalctl -u redflag-agent -n 50"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "⚠ Service not started (agent not registered)"
|
||||
echo " Run registration command above, then:"
|
||||
echo " sudo systemctl enable redflag-agent"
|
||||
echo " sudo systemctl start redflag-agent"
|
||||
fi
|
||||
|
||||
# Step 8: Show status
|
||||
echo ""
|
||||
echo "=== Installation Complete ==="
|
||||
echo ""
|
||||
echo "The RedFlag agent has been installed with the following security features:"
|
||||
echo " ✓ Dedicated system user (redflag-agent)"
|
||||
echo " ✓ Limited sudo access via /etc/sudoers.d/redflag-agent"
|
||||
echo " ✓ Systemd service with security hardening"
|
||||
echo " ✓ Protected configuration directory"
|
||||
echo ""
|
||||
if systemctl is-active --quiet redflag-agent; then
|
||||
echo "Service Status: ✓ RUNNING"
|
||||
echo ""
|
||||
systemctl status redflag-agent --no-pager -l | head -n 15
|
||||
echo ""
|
||||
else
|
||||
echo "Service Status: ⚠ NOT RUNNING (waiting for registration)"
|
||||
echo ""
|
||||
fi
|
||||
echo "Useful commands:"
|
||||
echo " Check status: sudo systemctl status redflag-agent"
|
||||
echo " View logs: sudo journalctl -u redflag-agent -f"
|
||||
echo " Restart: sudo systemctl restart redflag-agent"
|
||||
echo " Stop: sudo systemctl stop redflag-agent"
|
||||
echo ""
|
||||
echo "Configuration:"
|
||||
echo " Config file: $CONFIG_DIR/config.json"
|
||||
echo " Binary: $AGENT_BINARY"
|
||||
echo " Service: $SERVICE_FILE"
|
||||
echo " Sudoers: $SUDOERS_FILE"
|
||||
echo ""
|
||||
`
|
||||
|
||||
case "windows":
|
||||
return `@echo off
|
||||
REM RedFlag Agent Installation Script for Windows
|
||||
REM This script downloads the agent and sets up Windows service
|
||||
REM
|
||||
REM Usage:
|
||||
REM install.bat - Interactive mode (prompts for token)
|
||||
REM install.bat YOUR_TOKEN_HERE - Automatic mode (uses provided token)
|
||||
|
||||
set REDFLAG_SERVER=` + baseURL + `
|
||||
set AGENT_DIR=%ProgramFiles%\RedFlag
|
||||
set AGENT_BINARY=%AGENT_DIR%\redflag-agent.exe
|
||||
set CONFIG_DIR=%ProgramData%\RedFlag
|
||||
|
||||
echo Downloading RedFlag agent from %REDFLAG_SERVER%...
|
||||
curl -sfL "%REDFLAG_SERVER%/api/v1/downloads/windows-amd64" -o redflag-agent.exe
|
||||
echo === RedFlag Agent Installation ===
|
||||
echo.
|
||||
|
||||
echo Agent downloaded. Please visit %REDFLAG_SERVER%/admin to get a registration token.
|
||||
echo Then run: redflag-agent.exe --server %REDFLAG_SERVER% --token <YOUR_TOKEN%`
|
||||
REM Check for admin privileges
|
||||
net session >nul 2>&1
|
||||
if %errorLevel% neq 0 (
|
||||
echo ERROR: This script must be run as Administrator
|
||||
echo Right-click and select "Run as administrator"
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Detect architecture
|
||||
if "%PROCESSOR_ARCHITECTURE%"=="AMD64" (
|
||||
set DOWNLOAD_ARCH=amd64
|
||||
) else if "%PROCESSOR_ARCHITECTURE%"=="ARM64" (
|
||||
set DOWNLOAD_ARCH=arm64
|
||||
) else (
|
||||
echo ERROR: Unsupported architecture: %PROCESSOR_ARCHITECTURE%
|
||||
echo Supported: AMD64, ARM64
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Detected architecture: %PROCESSOR_ARCHITECTURE% (using windows-%DOWNLOAD_ARCH%)
|
||||
echo.
|
||||
|
||||
REM Create installation directory
|
||||
echo Creating installation directory...
|
||||
if not exist "%AGENT_DIR%" mkdir "%AGENT_DIR%"
|
||||
echo [OK] Installation directory created
|
||||
|
||||
REM Create config directory
|
||||
if not exist "%CONFIG_DIR%" mkdir "%CONFIG_DIR%"
|
||||
echo [OK] Configuration directory created
|
||||
|
||||
REM Grant full permissions to SYSTEM and Administrators on config directory
|
||||
echo Setting permissions on configuration directory...
|
||||
icacls "%CONFIG_DIR%" /grant "SYSTEM:(OI)(CI)F"
|
||||
icacls "%CONFIG_DIR%" /grant "Administrators:(OI)(CI)F"
|
||||
echo [OK] Permissions set
|
||||
echo.
|
||||
|
||||
REM Stop existing service if running (to allow binary update)
|
||||
sc query RedFlagAgent >nul 2>&1
|
||||
if %errorLevel% equ 0 (
|
||||
echo Existing service detected - stopping to allow update...
|
||||
sc stop RedFlagAgent >nul 2>&1
|
||||
timeout /t 3 /nobreak >nul
|
||||
echo [OK] Service stopped
|
||||
)
|
||||
|
||||
REM Download agent binary
|
||||
echo Downloading agent binary...
|
||||
echo From: %REDFLAG_SERVER%/api/v1/downloads/windows-%DOWNLOAD_ARCH%
|
||||
curl -sfL "%REDFLAG_SERVER%/api/v1/downloads/windows-%DOWNLOAD_ARCH%" -o "%AGENT_BINARY%"
|
||||
if %errorLevel% neq 0 (
|
||||
echo ERROR: Failed to download agent binary
|
||||
echo Please ensure %REDFLAG_SERVER% is accessible
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [OK] Agent binary downloaded
|
||||
echo.
|
||||
|
||||
REM Agent registration
|
||||
echo === Agent Registration ===
|
||||
echo.
|
||||
|
||||
REM Check if token was provided as command-line argument
|
||||
if not "%1"=="" (
|
||||
set TOKEN=%1
|
||||
echo Using provided registration token
|
||||
) else (
|
||||
echo IMPORTANT: You need a registration token to enroll this agent.
|
||||
echo.
|
||||
echo To get a token:
|
||||
echo 1. Visit: %REDFLAG_SERVER%/settings/tokens
|
||||
echo 2. Create a new registration token
|
||||
echo 3. Copy the token
|
||||
echo.
|
||||
set /p TOKEN="Enter registration token (or press Enter to skip): "
|
||||
)
|
||||
|
||||
REM Check if agent is already registered
|
||||
if exist "%CONFIG_DIR%\config.json" (
|
||||
echo.
|
||||
echo [INFO] Agent already registered - configuration file exists
|
||||
echo [INFO] Skipping registration to preserve agent history
|
||||
echo [INFO] If you need to re-register, delete: %CONFIG_DIR%\config.json
|
||||
echo.
|
||||
) else if not "%TOKEN%"=="" (
|
||||
echo.
|
||||
echo === Registering Agent ===
|
||||
echo.
|
||||
|
||||
REM Attempt registration
|
||||
"%AGENT_BINARY%" --server "%REDFLAG_SERVER%" --token "%TOKEN%" --register
|
||||
|
||||
REM Check exit code
|
||||
if %errorLevel% equ 0 (
|
||||
echo [OK] Agent registered successfully
|
||||
echo [OK] Configuration saved to: %CONFIG_DIR%\config.json
|
||||
echo.
|
||||
) else (
|
||||
echo.
|
||||
echo [ERROR] Registration failed
|
||||
echo.
|
||||
echo Please check:
|
||||
echo 1. Server is accessible: %REDFLAG_SERVER%
|
||||
echo 2. Registration token is valid and not expired
|
||||
echo 3. Token has available seats remaining
|
||||
echo.
|
||||
echo To try again:
|
||||
echo "%AGENT_BINARY%" --server "%REDFLAG_SERVER%" --token "%TOKEN%" --register
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
) else (
|
||||
echo.
|
||||
echo [INFO] No registration token provided - skipping registration
|
||||
echo.
|
||||
echo To register later:
|
||||
echo "%AGENT_BINARY%" --server "%REDFLAG_SERVER%" --token YOUR_TOKEN --register
|
||||
)
|
||||
|
||||
REM Check if service already exists
|
||||
echo.
|
||||
echo === Configuring Windows Service ===
|
||||
echo.
|
||||
sc query RedFlagAgent >nul 2>&1
|
||||
if %errorLevel% equ 0 (
|
||||
echo [INFO] RedFlag Agent service already installed
|
||||
echo [INFO] Service will be restarted with updated binary
|
||||
echo.
|
||||
) else (
|
||||
echo Installing RedFlag Agent service...
|
||||
"%AGENT_BINARY%" -install-service
|
||||
if %errorLevel% equ 0 (
|
||||
echo [OK] Service installed successfully
|
||||
echo.
|
||||
|
||||
REM Give Windows SCM time to register the service
|
||||
timeout /t 2 /nobreak >nul
|
||||
) else (
|
||||
echo [ERROR] Failed to install service
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
REM Start the service if agent is registered
|
||||
if exist "%CONFIG_DIR%\config.json" (
|
||||
echo Starting RedFlag Agent service...
|
||||
"%AGENT_BINARY%" -start-service
|
||||
if %errorLevel% equ 0 (
|
||||
echo [OK] RedFlag Agent service started
|
||||
echo.
|
||||
echo Agent is now running as a Windows service in the background.
|
||||
echo You can verify it is working by checking the agent status in the web UI.
|
||||
) else (
|
||||
echo [WARNING] Failed to start service. You can start it manually:
|
||||
echo "%AGENT_BINARY%" -start-service
|
||||
echo Or use Windows Services: services.msc
|
||||
)
|
||||
) else (
|
||||
echo [WARNING] Service not started (agent not registered)
|
||||
echo To register and start the service:
|
||||
echo 1. Register: "%AGENT_BINARY%" --server "%REDFLAG_SERVER%" --token YOUR_TOKEN --register
|
||||
echo 2. Start: "%AGENT_BINARY%" -start-service
|
||||
)
|
||||
|
||||
echo.
|
||||
echo === Installation Complete ===
|
||||
echo.
|
||||
echo The RedFlag agent has been installed as a Windows service.
|
||||
echo Configuration file: %CONFIG_DIR%\config.json
|
||||
echo Agent binary: %AGENT_BINARY%
|
||||
echo.
|
||||
echo Managing the RedFlag Agent service:
|
||||
echo Check status: "%AGENT_BINARY%" -service-status
|
||||
echo Start manually: "%AGENT_BINARY%" -start-service
|
||||
echo Stop service: "%AGENT_BINARY%" -stop-service
|
||||
echo Remove service: "%AGENT_BINARY%" -remove-service
|
||||
echo.
|
||||
echo Alternative management with Windows Services:
|
||||
echo Open services.msc and look for "RedFlag Update Agent"
|
||||
echo.
|
||||
echo To run the agent directly (for debugging):
|
||||
echo "%AGENT_BINARY%"
|
||||
echo.
|
||||
echo To verify the agent is working:
|
||||
echo 1. Check the web UI for the agent status
|
||||
echo 2. Look for recent check-ins from this machine
|
||||
echo.
|
||||
pause
|
||||
`
|
||||
|
||||
default:
|
||||
return "# Unsupported platform"
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/Fimeg/RedFlag/aggregator-server/internal/config"
|
||||
"github.com/Fimeg/RedFlag/aggregator-server/internal/database/queries"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type RegistrationTokenHandler struct {
|
||||
@@ -29,6 +30,7 @@ func (h *RegistrationTokenHandler) GenerateRegistrationToken(c *gin.Context) {
|
||||
var request struct {
|
||||
Label string `json:"label" binding:"required"`
|
||||
ExpiresIn string `json:"expires_in"` // e.g., "24h", "7d", "168h"
|
||||
MaxSeats int `json:"max_seats"` // Number of agents that can use this token
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
|
||||
@@ -86,8 +88,14 @@ func (h *RegistrationTokenHandler) GenerateRegistrationToken(c *gin.Context) {
|
||||
metadata["server_url"] = c.Request.Host
|
||||
metadata["expires_in"] = expiresIn
|
||||
|
||||
// Default max_seats to 1 if not provided or invalid
|
||||
maxSeats := request.MaxSeats
|
||||
if maxSeats < 1 {
|
||||
maxSeats = 1
|
||||
}
|
||||
|
||||
// Store token in database
|
||||
err = h.tokenQueries.CreateRegistrationToken(token, request.Label, expiresAt, metadata)
|
||||
err = h.tokenQueries.CreateRegistrationToken(token, request.Label, expiresAt, maxSeats, metadata)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create token"})
|
||||
return
|
||||
@@ -117,6 +125,7 @@ func (h *RegistrationTokenHandler) ListRegistrationTokens(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
status := c.Query("status")
|
||||
isActive := c.Query("is_active") == "true"
|
||||
|
||||
// Validate pagination
|
||||
if limit > 100 {
|
||||
@@ -131,10 +140,26 @@ func (h *RegistrationTokenHandler) ListRegistrationTokens(c *gin.Context) {
|
||||
var tokens []queries.RegistrationToken
|
||||
var err error
|
||||
|
||||
if status != "" {
|
||||
// TODO: Add filtered queries by status
|
||||
tokens, err = h.tokenQueries.GetAllRegistrationTokens(limit, offset)
|
||||
// Handle filtering by active status
|
||||
if isActive || status == "active" {
|
||||
// Get only active tokens (no pagination for active-only queries)
|
||||
tokens, err = h.tokenQueries.GetActiveRegistrationTokens()
|
||||
|
||||
// Apply manual pagination to active tokens if needed
|
||||
if err == nil && len(tokens) > 0 {
|
||||
start := offset
|
||||
end := offset + limit
|
||||
if start >= len(tokens) {
|
||||
tokens = []queries.RegistrationToken{}
|
||||
} else {
|
||||
if end > len(tokens) {
|
||||
end = len(tokens)
|
||||
}
|
||||
tokens = tokens[start:end]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Get all tokens with database-level pagination
|
||||
tokens, err = h.tokenQueries.GetAllRegistrationTokens(limit, offset)
|
||||
}
|
||||
|
||||
@@ -213,6 +238,34 @@ func (h *RegistrationTokenHandler) RevokeRegistrationToken(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Token revoked successfully"})
|
||||
}
|
||||
|
||||
// DeleteRegistrationToken permanently deletes a registration token
|
||||
func (h *RegistrationTokenHandler) DeleteRegistrationToken(c *gin.Context) {
|
||||
tokenID := c.Param("id")
|
||||
if tokenID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Token ID is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse UUID
|
||||
id, err := uuid.Parse(tokenID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid token ID format"})
|
||||
return
|
||||
}
|
||||
|
||||
err = h.tokenQueries.DeleteRegistrationToken(id)
|
||||
if err != nil {
|
||||
if err.Error() == "token not found" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Token not found"})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete token"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Token deleted successfully"})
|
||||
}
|
||||
|
||||
// ValidateRegistrationToken checks if a token is valid (for testing/debugging)
|
||||
func (h *RegistrationTokenHandler) ValidateRegistrationToken(c *gin.Context) {
|
||||
token := c.Query("token")
|
||||
|
||||
@@ -2,16 +2,15 @@ package handlers
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/lib/pq"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// SetupHandler handles server configuration
|
||||
@@ -25,8 +24,81 @@ func NewSetupHandler(configPath string) *SetupHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// updatePostgresPassword updates the PostgreSQL user password
|
||||
func updatePostgresPassword(dbHost, dbPort, dbUser, currentPassword, newPassword string) error {
|
||||
// Connect to PostgreSQL with current credentials
|
||||
connStr := fmt.Sprintf("postgres://%s:%s@%s:%s/postgres?sslmode=disable", dbUser, currentPassword, dbHost, dbPort)
|
||||
|
||||
db, err := sql.Open("postgres", connStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to PostgreSQL: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Test connection
|
||||
if err := db.Ping(); err != nil {
|
||||
return fmt.Errorf("failed to ping PostgreSQL: %v", err)
|
||||
}
|
||||
|
||||
// Update the password
|
||||
_, err = db.Exec("ALTER USER "+pq.QuoteIdentifier(dbUser)+" PASSWORD '"+newPassword+"'")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update PostgreSQL password: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("PostgreSQL password updated successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// createSharedEnvContentForDisplay generates the .env file content for display
|
||||
func createSharedEnvContentForDisplay(req struct {
|
||||
AdminUser string `json:"adminUser"`
|
||||
AdminPass string `json:"adminPassword"`
|
||||
DBHost string `json:"dbHost"`
|
||||
DBPort string `json:"dbPort"`
|
||||
DBName string `json:"dbName"`
|
||||
DBUser string `json:"dbUser"`
|
||||
DBPassword string `json:"dbPassword"`
|
||||
ServerHost string `json:"serverHost"`
|
||||
ServerPort string `json:"serverPort"`
|
||||
MaxSeats string `json:"maxSeats"`
|
||||
}, jwtSecret string) (string, error) {
|
||||
// Generate .env file content for user to copy
|
||||
envContent := fmt.Sprintf(`# RedFlag Environment Configuration
|
||||
# Generated by web setup - Save this content to ./config/.env
|
||||
|
||||
# PostgreSQL Configuration (for PostgreSQL container)
|
||||
POSTGRES_DB=%s
|
||||
POSTGRES_USER=%s
|
||||
POSTGRES_PASSWORD=%s
|
||||
|
||||
# RedFlag Server Configuration
|
||||
REDFLAG_SERVER_HOST=%s
|
||||
REDFLAG_SERVER_PORT=%s
|
||||
REDFLAG_DB_HOST=%s
|
||||
REDFLAG_DB_PORT=%s
|
||||
REDFLAG_DB_NAME=%s
|
||||
REDFLAG_DB_USER=%s
|
||||
REDFLAG_DB_PASSWORD=%s
|
||||
REDFLAG_ADMIN_USER=%s
|
||||
REDFLAG_ADMIN_PASSWORD=%s
|
||||
REDFLAG_JWT_SECRET=%s
|
||||
REDFLAG_TOKEN_EXPIRY=24h
|
||||
REDFLAG_MAX_TOKENS=100
|
||||
REDFLAG_MAX_SEATS=%s`,
|
||||
req.DBName, req.DBUser, req.DBPassword,
|
||||
req.ServerHost, req.ServerPort,
|
||||
req.DBHost, req.DBPort, req.DBName, req.DBUser, req.DBPassword,
|
||||
req.AdminUser, req.AdminPass, jwtSecret, req.MaxSeats)
|
||||
|
||||
return envContent, nil
|
||||
}
|
||||
|
||||
// ShowSetupPage displays the web setup interface
|
||||
func (h *SetupHandler) ShowSetupPage(c *gin.Context) {
|
||||
// Display setup page - configuration will be generated via web interface
|
||||
fmt.Println("Showing setup page - configuration will be generated via web interface")
|
||||
|
||||
html := `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -46,19 +118,16 @@ func (h *SetupHandler) ShowSetupPage(c *gin.Context) {
|
||||
.form-section h3 { color: #4f46e5; margin-bottom: 15px; font-size: 1.2rem; }
|
||||
.form-group { margin-bottom: 20px; }
|
||||
label { display: block; margin-bottom: 5px; font-weight: 500; color: #374151; }
|
||||
input, select { width: 100%; padding: 12px; border: 2px solid #e5e7eb; border-radius: 6px; font-size: 1rem; transition: border-color 0.3s; }
|
||||
input, select { width: 100%%; padding: 12px; border: 2px solid #e5e7eb; border-radius: 6px; font-size: 1rem; transition: border-color 0.3s; }
|
||||
input:focus, select:focus { outline: none; border-color: #4f46e5; box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1); }
|
||||
input[type="password"] { font-family: monospace; }
|
||||
.button { background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%); color: white; border: none; padding: 14px 28px; border-radius: 6px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: transform 0.2s; }
|
||||
.button:hover { transform: translateY(-1px); }
|
||||
.button:active { transform: translateY(0); }
|
||||
.progress { background: #f3f4f6; border-radius: 6px; height: 8px; overflow: hidden; margin: 20px 0; }
|
||||
.progress-bar { background: linear-gradient(90deg, #4f46e5, #7c3aed); height: 100%; width: 0%; transition: width 0.3s; }
|
||||
.status { text-align: center; padding: 20px; display: none; }
|
||||
.error { background: #fef2f2; color: #dc2626; padding: 15px; border-radius: 6px; margin: 20px 0; border: 1px solid #fecaca; }
|
||||
.success { background: #f0fdf4; color: #16a34a; padding: 15px; border-radius: 6px; margin: 20px 0; border: 1px solid #bbf7d0; }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||
@media (max-width: 768px) { .grid { grid-template-columns: 1fr; } }
|
||||
.btn { background: linear-gradient(135deg, #4f46e5 0%%, #7c3aed 100%%); color: white; border: none; padding: 14px 28px; border-radius: 6px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: transform 0.2s; }
|
||||
.btn:hover { transform: translateY(-2px); }
|
||||
.btn:disabled { opacity: 0.6; cursor: not-allowed; transform: none; }
|
||||
.success { color: #10b981; background: #ecfdf5; padding: 12px; border-radius: 6px; border: 1px solid #10b981; }
|
||||
.error { color: #ef4444; background: #fef2f2; padding: 12px; border-radius: 6px; border: 1px solid #ef4444; }
|
||||
.loading { display: none; text-align: center; margin: 20px 0; }
|
||||
.spinner { border: 3px solid #f3f3f3; border-top: 3px solid #4f46e5; border-radius: 50%%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 0 auto; }
|
||||
@keyframes spin { 0%% { transform: rotate(0deg); } 100%% { transform: rotate(360deg); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -66,27 +135,24 @@ func (h *SetupHandler) ShowSetupPage(c *gin.Context) {
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
<h1>🚀 RedFlag Server Setup</h1>
|
||||
<p class="subtitle">Configure your update management server</p>
|
||||
<p class="subtitle">Configure your RedFlag deployment</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<form id="setupForm">
|
||||
<div class="form-section">
|
||||
<h3>🔐 Admin Account</h3>
|
||||
<div class="grid">
|
||||
<h3>📊 Server Configuration</h3>
|
||||
<div class="form-group">
|
||||
<label for="adminUser">Admin Username</label>
|
||||
<input type="text" id="adminUser" name="adminUser" value="admin" required>
|
||||
<label for="serverHost">Server Host</label>
|
||||
<input type="text" id="serverHost" name="serverHost" value="0.0.0.0" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="adminPassword">Admin Password</label>
|
||||
<input type="password" id="adminPassword" name="adminPassword" required>
|
||||
</div>
|
||||
<label for="serverPort">Server Port</label>
|
||||
<input type="number" id="serverPort" name="serverPort" value="8080" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h3>💾 Database Configuration</h3>
|
||||
<div class="grid">
|
||||
<h3>🗄️ Database Configuration</h3>
|
||||
<div class="form-group">
|
||||
<label for="dbHost">Database Host</label>
|
||||
<input type="text" id="dbHost" name="dbHost" value="postgres" required>
|
||||
@@ -105,37 +171,42 @@ func (h *SetupHandler) ShowSetupPage(c *gin.Context) {
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="dbPassword">Database Password</label>
|
||||
<input type="password" id="dbPassword" name="dbPassword" value="redflag" required>
|
||||
</div>
|
||||
<input type="password" id="dbPassword" name="dbPassword" placeholder="Enter a secure database password" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h3>🌐 Server Configuration</h3>
|
||||
<div class="grid">
|
||||
<h3>👤 Administrator Account</h3>
|
||||
<div class="form-group">
|
||||
<label for="serverHost">Server Host</label>
|
||||
<input type="text" id="serverHost" name="serverHost" value="0.0.0.0" required>
|
||||
<label for="adminUser">Admin Username</label>
|
||||
<input type="text" id="adminUser" name="adminUser" value="admin" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="serverPort">Server Port</label>
|
||||
<input type="number" id="serverPort" name="serverPort" value="8080" required>
|
||||
<label for="adminPassword">Admin Password</label>
|
||||
<input type="password" id="adminPassword" name="adminPassword" placeholder="Enter a secure admin password" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h3>🔧 Agent Settings</h3>
|
||||
<div class="form-group">
|
||||
<label for="maxSeats">Maximum Agent Seats</label>
|
||||
<input type="number" id="maxSeats" name="maxSeats" value="50" min="1" max="1000">
|
||||
<input type="number" id="maxSeats" name="maxSeats" value="50" min="1" max="1000" required>
|
||||
<small style="color: #6b7280; font-size: 0.875rem;">Maximum number of agents that can register</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress" id="progress" style="display: none;">
|
||||
<div class="progress-bar" id="progressBar"></div>
|
||||
</div>
|
||||
|
||||
<div id="status" class="status"></div>
|
||||
|
||||
<button type="submit" class="button">Configure Server</button>
|
||||
<button type="submit" class="btn" id="submitBtn">
|
||||
🚀 Configure RedFlag Server
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="loading" id="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Configuring your RedFlag server...</p>
|
||||
</div>
|
||||
|
||||
<div id="result"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,56 +215,113 @@ func (h *SetupHandler) ShowSetupPage(c *gin.Context) {
|
||||
document.getElementById('setupForm').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(e.target);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const loading = document.getElementById('loading');
|
||||
const result = document.getElementById('result');
|
||||
|
||||
const progress = document.getElementById('progress');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const status = document.getElementById('status');
|
||||
const submitButton = e.target.querySelector('button[type="submit"]');
|
||||
// Get form values
|
||||
const formData = {
|
||||
serverHost: document.getElementById('serverHost').value,
|
||||
serverPort: document.getElementById('serverPort').value,
|
||||
dbHost: document.getElementById('dbHost').value,
|
||||
dbPort: document.getElementById('dbPort').value,
|
||||
dbName: document.getElementById('dbName').value,
|
||||
dbUser: document.getElementById('dbUser').value,
|
||||
dbPassword: document.getElementById('dbPassword').value,
|
||||
adminUser: document.getElementById('adminUser').value,
|
||||
adminPassword: document.getElementById('adminPassword').value,
|
||||
maxSeats: document.getElementById('maxSeats').value
|
||||
};
|
||||
|
||||
// Show progress and disable button
|
||||
progress.style.display = 'block';
|
||||
submitButton.disabled = true;
|
||||
submitButton.textContent = 'Configuring...';
|
||||
// Validate inputs
|
||||
if (!formData.adminUser || !formData.adminPassword) {
|
||||
result.innerHTML = '<div class="error">❌ Admin username and password are required</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.dbHost || !formData.dbPort || !formData.dbName || !formData.dbUser || !formData.dbPassword) {
|
||||
result.innerHTML = '<div class="error">❌ All database fields are required</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading
|
||||
submitBtn.disabled = true;
|
||||
loading.style.display = 'block';
|
||||
result.innerHTML = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/setup', {
|
||||
const response = await fetch('/api/setup/configure', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
const resultData = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
// Success
|
||||
progressBar.style.width = '100%';
|
||||
status.innerHTML = '<div class="success">✅ ' + result.message + '</div>';
|
||||
submitButton.textContent = 'Configuration Complete';
|
||||
let resultHtml = '<div class="success">';
|
||||
resultHtml += '<h3>✅ Configuration Generated Successfully!</h3>';
|
||||
resultHtml += '<p><strong>Your JWT Secret:</strong> <code style="background: #f3f4f6; padding: 2px 6px; border-radius: 3px;">' + resultData.jwtSecret + '</code> ';
|
||||
resultHtml += '<button onclick="copyJWT(\'' + resultData.jwtSecret + '\')" style="background: #4f46e5; color: white; border: none; padding: 4px 8px; border-radius: 3px; cursor: pointer; font-size: 0.8rem;">📋 Copy</button></p>';
|
||||
resultHtml += '<p><strong>⚠️ Important Next Steps:</strong></p>';
|
||||
resultHtml += '<div style="background: #fef3c7; border: 1px solid #f59e0b; border-radius: 6px; padding: 15px; margin: 15px 0;">';
|
||||
resultHtml += '<p style="margin: 0; color: #92400e;"><strong>🔧 Complete Setup Required:</strong></p>';
|
||||
resultHtml += '<ol style="margin: 10px 0 0 0; color: #92400e;">';
|
||||
resultHtml += '<li>Replace the bootstrap environment variables with the newly generated ones below</li>';
|
||||
resultHtml += '<li>Run: <code style="background: #fef3c7; padding: 2px 6px; border-radius: 3px;">' + resultData.manualRestartCommand + '</code></li>';
|
||||
resultHtml += '</ol>';
|
||||
resultHtml += '<p style="margin: 10px 0 0 0; color: #92400e; font-size: 0.9rem;"><strong>This step is required to apply your configuration and run database migrations.</strong></p>';
|
||||
resultHtml += '</div>';
|
||||
resultHtml += '</div>';
|
||||
|
||||
resultHtml += '<div style="margin-top: 20px;">';
|
||||
resultHtml += '<h4>📄 Configuration Content:</h4>';
|
||||
resultHtml += '<textarea readonly style="width: 100%%; height: 300px; font-family: monospace; font-size: 0.85rem; padding: 10px; border: 1px solid #d1d5db; border-radius: 6px; background: #f9fafb;">' + resultData.envContent + '</textarea>';
|
||||
resultHtml += '<button onclick="copyConfig()" style="background: #10b981; color: white; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; margin-top: 10px;">📋 Copy All Configuration</button>';
|
||||
resultHtml += '</div>';
|
||||
|
||||
result.innerHTML = resultHtml;
|
||||
loading.style.display = 'none';
|
||||
|
||||
// Store JWT for copy function
|
||||
window.jwtSecret = resultData.jwtSecret;
|
||||
window.envContent = resultData.envContent;
|
||||
|
||||
// Redirect to admin interface after delay
|
||||
setTimeout(() => {
|
||||
window.location.href = '/admin';
|
||||
}, 3000);
|
||||
} else {
|
||||
// Error
|
||||
status.innerHTML = '<div class="error">❌ ' + result.error + '</div>';
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = 'Configure Server';
|
||||
result.innerHTML = '<div class="error">❌ Error: ' + resultData.error + '</div>';
|
||||
submitBtn.disabled = false;
|
||||
loading.style.display = 'none';
|
||||
}
|
||||
} catch (error) {
|
||||
status.innerHTML = '<div class="error">❌ Network error: ' + error.message + '</div>';
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = 'Configure Server';
|
||||
result.innerHTML = '<div class="error">❌ Network error: ' + error.message + '</div>';
|
||||
submitBtn.disabled = false;
|
||||
loading.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
function copyJWT(jwt) {
|
||||
navigator.clipboard.writeText(jwt).then(() => {
|
||||
alert('JWT secret copied to clipboard!');
|
||||
}).catch(() => {
|
||||
prompt('Copy this JWT secret:', jwt);
|
||||
});
|
||||
}
|
||||
|
||||
function copyConfig() {
|
||||
if (window.envContent) {
|
||||
navigator.clipboard.writeText(window.envContent).then(() => {
|
||||
alert('Configuration copied to clipboard!');
|
||||
}).catch(() => {
|
||||
prompt('Copy this configuration:', window.envContent);
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
c.Data(200, "text/html; charset=utf-8", []byte(html))
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(html))
|
||||
}
|
||||
|
||||
// ConfigureServer handles the configuration submission
|
||||
@@ -246,95 +374,36 @@ func (h *SetupHandler) ConfigureServer(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create configuration content
|
||||
envContent := fmt.Sprintf(`# RedFlag Server Configuration
|
||||
# Generated by web setup
|
||||
// Generate JWT secret for display (not logged for security)
|
||||
jwtSecret := deriveJWTSecret(req.AdminUser, req.AdminPass)
|
||||
|
||||
# Server Configuration
|
||||
REDFLAG_SERVER_HOST=%s
|
||||
REDFLAG_SERVER_PORT=%d
|
||||
REDFLAG_TLS_ENABLED=false
|
||||
# REDFLAG_TLS_CERT_FILE=
|
||||
# REDFLAG_TLS_KEY_FILE=
|
||||
|
||||
# Database Configuration
|
||||
REDFLAG_DB_HOST=%s
|
||||
REDFLAG_DB_PORT=%d
|
||||
REDFLAG_DB_NAME=%s
|
||||
REDFLAG_DB_USER=%s
|
||||
REDFLAG_DB_PASSWORD=%s
|
||||
|
||||
# Admin Configuration
|
||||
REDFLAG_ADMIN_USER=%s
|
||||
REDFLAG_ADMIN_PASSWORD=%s
|
||||
REDFLAG_JWT_SECRET=%s
|
||||
|
||||
# Agent Registration
|
||||
REDFLAG_TOKEN_EXPIRY=24h
|
||||
REDFLAG_MAX_TOKENS=100
|
||||
REDFLAG_MAX_SEATS=%d
|
||||
|
||||
# Legacy Configuration (for backwards compatibility)
|
||||
SERVER_PORT=%d
|
||||
DATABASE_URL=postgres://%s:%s@%s:%d/%s?sslmode=disable
|
||||
JWT_SECRET=%s
|
||||
CHECK_IN_INTERVAL=300
|
||||
OFFLINE_THRESHOLD=600
|
||||
TIMEZONE=UTC
|
||||
LATEST_AGENT_VERSION=0.1.16`,
|
||||
req.ServerHost, serverPort,
|
||||
req.DBHost, dbPort, req.DBName, req.DBUser, req.DBPassword,
|
||||
req.AdminUser, req.AdminPass, deriveJWTSecret(req.AdminUser, req.AdminPass),
|
||||
maxSeats,
|
||||
serverPort, req.DBUser, req.DBPassword, req.DBHost, dbPort, req.DBName, deriveJWTSecret(req.AdminUser, req.AdminPass))
|
||||
|
||||
// Write configuration to persistent location
|
||||
configDir := "/app/config"
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
fmt.Printf("Failed to create config directory: %v\n", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create config directory: %v", err)})
|
||||
return
|
||||
// Step 1: Update PostgreSQL password from bootstrap to user password
|
||||
fmt.Println("Updating PostgreSQL password from bootstrap to user-provided password...")
|
||||
bootstrapPassword := "redflag_bootstrap" // This matches our bootstrap .env
|
||||
if err := updatePostgresPassword(req.DBHost, req.DBPort, req.DBUser, bootstrapPassword, req.DBPassword); err != nil {
|
||||
fmt.Printf("Warning: Failed to update PostgreSQL password: %v\n", err)
|
||||
fmt.Println("Will proceed with configuration anyway...")
|
||||
}
|
||||
|
||||
envPath := filepath.Join(configDir, ".env")
|
||||
if err := os.WriteFile(envPath, []byte(envContent), 0600); err != nil {
|
||||
fmt.Printf("Failed to save configuration: %v\n", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to save configuration: %v", err)})
|
||||
return
|
||||
}
|
||||
// Step 2: Generate configuration content for manual update
|
||||
fmt.Println("Generating configuration content for manual .env file update...")
|
||||
|
||||
// Trigger graceful server restart after configuration
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second) // Give response time to reach client
|
||||
|
||||
// Get the current executable path
|
||||
execPath, err := os.Executable()
|
||||
// Generate the complete .env file content for the user to copy
|
||||
newEnvContent, err := createSharedEnvContentForDisplay(req, jwtSecret)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to get executable path: %v\n", err)
|
||||
fmt.Printf("Failed to generate .env content: %v\n", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate configuration content"})
|
||||
return
|
||||
}
|
||||
|
||||
// Restart the server with the same executable
|
||||
cmd := exec.Command(execPath)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
|
||||
// Start the new process
|
||||
if err := cmd.Start(); err != nil {
|
||||
fmt.Printf("Failed to start new server process: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Exit the current process gracefully
|
||||
fmt.Printf("Server restarting... PID: %d\n", cmd.Process.Pid)
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Configuration saved successfully! Server will restart automatically.",
|
||||
"configPath": envPath,
|
||||
"restart": true,
|
||||
"message": "Configuration generated successfully!",
|
||||
"jwtSecret": jwtSecret,
|
||||
"envContent": newEnvContent,
|
||||
"restartMessage": "Please replace the bootstrap environment variables with the newly generated ones, then run: docker-compose down && docker-compose up -d",
|
||||
"manualRestartRequired": true,
|
||||
"manualRestartCommand": "docker-compose down && docker-compose up -d",
|
||||
"configFilePath": "./config/.env",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -6,13 +6,7 @@ import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// Config holds the application configuration
|
||||
@@ -20,6 +14,7 @@ type Config struct {
|
||||
Server struct {
|
||||
Host string `env:"REDFLAG_SERVER_HOST" default:"0.0.0.0"`
|
||||
Port int `env:"REDFLAG_SERVER_PORT" default:"8080"`
|
||||
PublicURL string `env:"REDFLAG_PUBLIC_URL"` // Optional: External URL for reverse proxy/load balancer
|
||||
TLS struct {
|
||||
Enabled bool `env:"REDFLAG_TLS_ENABLED" default:"false"`
|
||||
CertFile string `env:"REDFLAG_TLS_CERT_FILE"`
|
||||
@@ -49,17 +44,9 @@ type Config struct {
|
||||
LatestAgentVersion string
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables
|
||||
// Load reads configuration from environment variables only (immutable configuration)
|
||||
func Load() (*Config, error) {
|
||||
// Load .env file from persistent config directory
|
||||
configPaths := []string{"/app/config/.env", ".env"}
|
||||
|
||||
for _, path := range configPaths {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
_ = godotenv.Load(path)
|
||||
break
|
||||
}
|
||||
}
|
||||
fmt.Printf("[CONFIG] Loading configuration from environment variables\n")
|
||||
|
||||
cfg := &Config{}
|
||||
|
||||
@@ -67,6 +54,7 @@ func Load() (*Config, error) {
|
||||
cfg.Server.Host = getEnv("REDFLAG_SERVER_HOST", "0.0.0.0")
|
||||
serverPort, _ := strconv.Atoi(getEnv("REDFLAG_SERVER_PORT", "8080"))
|
||||
cfg.Server.Port = serverPort
|
||||
cfg.Server.PublicURL = getEnv("REDFLAG_PUBLIC_URL", "") // Optional external URL
|
||||
cfg.Server.TLS.Enabled = getEnv("REDFLAG_TLS_ENABLED", "false") == "true"
|
||||
cfg.Server.TLS.CertFile = getEnv("REDFLAG_TLS_CERT_FILE", "")
|
||||
cfg.Server.TLS.KeyFile = getEnv("REDFLAG_TLS_KEY_FILE", "")
|
||||
@@ -106,6 +94,13 @@ func Load() (*Config, error) {
|
||||
return nil, fmt.Errorf("missing required configuration")
|
||||
}
|
||||
|
||||
// Check if we're using bootstrap defaults that need to be replaced
|
||||
if cfg.Admin.Password == "changeme" || cfg.Admin.JWTSecret == "bootstrap-jwt-secret-replace-in-setup" || cfg.Database.Password == "redflag_bootstrap" {
|
||||
fmt.Printf("[INFO] Server running with bootstrap configuration - setup required\n")
|
||||
fmt.Printf("[INFO] Configure via web interface at: http://localhost:8080/setup\n")
|
||||
return nil, fmt.Errorf("bootstrap configuration detected - setup required")
|
||||
}
|
||||
|
||||
// Validate JWT secret is not the development default
|
||||
if cfg.Admin.JWTSecret == "test-secret-for-development-only" {
|
||||
fmt.Printf("[SECURITY WARNING] Using development JWT secret\n")
|
||||
@@ -115,103 +110,9 @@ func Load() (*Config, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// RunSetupWizard guides user through initial configuration
|
||||
// RunSetupWizard is deprecated - configuration is now handled via web interface
|
||||
func RunSetupWizard() error {
|
||||
fmt.Printf("RedFlag Server Setup Wizard\n")
|
||||
fmt.Printf("===========================\n\n")
|
||||
|
||||
// Admin credentials
|
||||
fmt.Printf("Admin Account Setup\n")
|
||||
fmt.Printf("--------------------\n")
|
||||
username := promptForInput("Admin username", "admin")
|
||||
password := promptForPassword("Admin password")
|
||||
|
||||
// Database configuration
|
||||
fmt.Printf("\nDatabase Configuration\n")
|
||||
fmt.Printf("----------------------\n")
|
||||
dbHost := promptForInput("Database host", "localhost")
|
||||
dbPort, _ := strconv.Atoi(promptForInput("Database port", "5432"))
|
||||
dbName := promptForInput("Database name", "redflag")
|
||||
dbUser := promptForInput("Database user", "redflag")
|
||||
dbPassword := promptForPassword("Database password")
|
||||
|
||||
// Server configuration
|
||||
fmt.Printf("\nServer Configuration\n")
|
||||
fmt.Printf("--------------------\n")
|
||||
serverHost := promptForInput("Server bind address", "0.0.0.0")
|
||||
serverPort, _ := strconv.Atoi(promptForInput("Server port", "8080"))
|
||||
|
||||
// Agent limits
|
||||
fmt.Printf("\nAgent Registration\n")
|
||||
fmt.Printf("------------------\n")
|
||||
maxSeats, _ := strconv.Atoi(promptForInput("Maximum agent seats (security limit)", "50"))
|
||||
|
||||
// Generate JWT secret from admin password
|
||||
jwtSecret := deriveJWTSecret(username, password)
|
||||
|
||||
// Create .env file
|
||||
envContent := fmt.Sprintf(`# RedFlag Server Configuration
|
||||
# Generated on %s
|
||||
|
||||
# Server Configuration
|
||||
REDFLAG_SERVER_HOST=%s
|
||||
REDFLAG_SERVER_PORT=%d
|
||||
REDFLAG_TLS_ENABLED=false
|
||||
# REDFLAG_TLS_CERT_FILE=
|
||||
# REDFLAG_TLS_KEY_FILE=
|
||||
|
||||
# Database Configuration
|
||||
REDFLAG_DB_HOST=%s
|
||||
REDFLAG_DB_PORT=%d
|
||||
REDFLAG_DB_NAME=%s
|
||||
REDFLAG_DB_USER=%s
|
||||
REDFLAG_DB_PASSWORD=%s
|
||||
|
||||
# Admin Configuration
|
||||
REDFLAG_ADMIN_USER=%s
|
||||
REDFLAG_ADMIN_PASSWORD=%s
|
||||
REDFLAG_JWT_SECRET=%s
|
||||
|
||||
# Agent Registration
|
||||
REDFLAG_TOKEN_EXPIRY=24h
|
||||
REDFLAG_MAX_TOKENS=100
|
||||
REDFLAG_MAX_SEATS=%d
|
||||
|
||||
# Legacy Configuration (for backwards compatibility)
|
||||
SERVER_PORT=%d
|
||||
DATABASE_URL=postgres://%s:%s@%s:%d/%s?sslmode=disable
|
||||
JWT_SECRET=%s
|
||||
CHECK_IN_INTERVAL=300
|
||||
OFFLINE_THRESHOLD=600
|
||||
TIMEZONE=UTC
|
||||
LATEST_AGENT_VERSION=0.1.8
|
||||
`, time.Now().Format("2006-01-02 15:04:05"), serverHost, serverPort,
|
||||
dbHost, dbPort, dbName, dbUser, dbPassword,
|
||||
username, password, jwtSecret, maxSeats,
|
||||
serverPort, dbUser, dbPassword, dbHost, dbPort, dbName, jwtSecret)
|
||||
|
||||
// Write .env file to persistent location
|
||||
configDir := "/app/config"
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create config directory: %w", err)
|
||||
}
|
||||
|
||||
envPath := filepath.Join(configDir, ".env")
|
||||
if err := os.WriteFile(envPath, []byte(envContent), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write .env file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n[OK] Configuration saved to .env file\n")
|
||||
fmt.Printf("[SECURITY] File permissions set to 0600 (owner read/write only)\n")
|
||||
fmt.Printf("\nNext steps:\n")
|
||||
fmt.Printf(" 1. Start database: %s:%d\n", dbHost, dbPort)
|
||||
fmt.Printf(" 2. Create database: CREATE DATABASE %s;\n", dbName)
|
||||
fmt.Printf(" 3. Run migrations: ./redflag-server --migrate\n")
|
||||
fmt.Printf(" 4. Start server: ./redflag-server\n")
|
||||
fmt.Printf("\nServer will be available at: http://%s:%d\n", serverHost, serverPort)
|
||||
fmt.Printf("Admin interface: http://%s:%d/admin\n", serverHost, serverPort)
|
||||
|
||||
return nil
|
||||
return fmt.Errorf("CLI setup wizard is deprecated. Please use the web interface at http://localhost:8080/setup for configuration")
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
@@ -221,28 +122,6 @@ func getEnv(key, defaultValue string) string {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func promptForInput(prompt, defaultValue string) string {
|
||||
fmt.Printf("%s [%s]: ", prompt, defaultValue)
|
||||
var input string
|
||||
fmt.Scanln(&input)
|
||||
if strings.TrimSpace(input) == "" {
|
||||
return defaultValue
|
||||
}
|
||||
return strings.TrimSpace(input)
|
||||
}
|
||||
|
||||
func promptForPassword(prompt string) string {
|
||||
fmt.Printf("%s: ", prompt)
|
||||
password, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
if err != nil {
|
||||
// Fallback to non-hidden input
|
||||
var input string
|
||||
fmt.Scanln(&input)
|
||||
return strings.TrimSpace(input)
|
||||
}
|
||||
fmt.Printf("\n")
|
||||
return strings.TrimSpace(string(password))
|
||||
}
|
||||
|
||||
func deriveJWTSecret(username, password string) string {
|
||||
// Derive JWT secret from admin credentials
|
||||
|
||||
@@ -35,8 +35,18 @@ func Connect(databaseURL string) (*DB, error) {
|
||||
return &DB{db}, nil
|
||||
}
|
||||
|
||||
// Migrate runs database migrations
|
||||
// Migrate runs database migrations with proper tracking
|
||||
func (db *DB) Migrate(migrationsPath string) error {
|
||||
// Create migrations table if it doesn't exist
|
||||
createTableSQL := `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version VARCHAR(255) PRIMARY KEY,
|
||||
applied_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
)`
|
||||
if _, err := db.Exec(createTableSQL); err != nil {
|
||||
return fmt.Errorf("failed to create migrations table: %w", err)
|
||||
}
|
||||
|
||||
// Read migration files
|
||||
files, err := os.ReadDir(migrationsPath)
|
||||
if err != nil {
|
||||
@@ -52,18 +62,67 @@ func (db *DB) Migrate(migrationsPath string) error {
|
||||
}
|
||||
sort.Strings(migrationFiles)
|
||||
|
||||
// Execute migrations
|
||||
// Execute migrations that haven't been applied yet
|
||||
for _, filename := range migrationFiles {
|
||||
// Check if migration has already been applied
|
||||
var count int
|
||||
err := db.Get(&count, "SELECT COUNT(*) FROM schema_migrations WHERE version = $1", filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check migration status for %s: %w", filename, err)
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
fmt.Printf("→ Skipping migration (already applied): %s\n", filename)
|
||||
continue
|
||||
}
|
||||
|
||||
// Read migration file
|
||||
path := filepath.Join(migrationsPath, filename)
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read migration %s: %w", filename, err)
|
||||
}
|
||||
|
||||
if _, err := db.Exec(string(content)); err != nil {
|
||||
// Execute migration in a transaction
|
||||
tx, err := db.Beginx()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction for migration %s: %w", filename, err)
|
||||
}
|
||||
|
||||
// Execute the migration SQL
|
||||
if _, err := tx.Exec(string(content)); err != nil {
|
||||
// Check if it's a "already exists" error - if so, handle gracefully
|
||||
if strings.Contains(err.Error(), "already exists") ||
|
||||
strings.Contains(err.Error(), "duplicate key") ||
|
||||
strings.Contains(err.Error(), "relation") && strings.Contains(err.Error(), "already exists") {
|
||||
fmt.Printf("⚠ Migration %s failed (objects already exist), marking as applied: %v\n", filename, err)
|
||||
// Rollback current transaction and start a new one for tracking
|
||||
tx.Rollback()
|
||||
// Start new transaction just for migration tracking
|
||||
if newTx, newTxErr := db.Beginx(); newTxErr == nil {
|
||||
if _, insertErr := newTx.Exec("INSERT INTO schema_migrations (version) VALUES ($1)", filename); insertErr == nil {
|
||||
newTx.Commit()
|
||||
} else {
|
||||
newTx.Rollback()
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("failed to execute migration %s: %w", filename, err)
|
||||
}
|
||||
|
||||
// Record the migration as applied
|
||||
if _, err := tx.Exec("INSERT INTO schema_migrations (version) VALUES ($1)", filename); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("failed to record migration %s: %w", filename, err)
|
||||
}
|
||||
|
||||
// Commit the transaction
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("failed to commit migration %s: %w", filename, err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Executed migration: %s\n", filename)
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -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
|
||||
@@ -27,12 +27,15 @@ type RegistrationToken struct {
|
||||
RevokedReason *string `json:"revoked_reason" db:"revoked_reason"`
|
||||
Status string `json:"status" db:"status"`
|
||||
CreatedBy string `json:"created_by" db:"created_by"`
|
||||
Metadata map[string]interface{} `json:"metadata" db:"metadata"`
|
||||
Metadata json.RawMessage `json:"metadata" db:"metadata"`
|
||||
MaxSeats int `json:"max_seats" db:"max_seats"`
|
||||
SeatsUsed int `json:"seats_used" db:"seats_used"`
|
||||
}
|
||||
|
||||
type TokenRequest struct {
|
||||
Label string `json:"label"`
|
||||
ExpiresIn string `json:"expires_in"` // e.g., "24h", "7d"
|
||||
MaxSeats int `json:"max_seats"` // Number of agents that can use this token (default: 1)
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
|
||||
@@ -47,19 +50,24 @@ func NewRegistrationTokenQueries(db *sqlx.DB) *RegistrationTokenQueries {
|
||||
return &RegistrationTokenQueries{db: db}
|
||||
}
|
||||
|
||||
// CreateRegistrationToken creates a new one-time use registration token
|
||||
func (q *RegistrationTokenQueries) CreateRegistrationToken(token, label string, expiresAt time.Time, metadata map[string]interface{}) error {
|
||||
// CreateRegistrationToken creates a new registration token with seat tracking
|
||||
func (q *RegistrationTokenQueries) CreateRegistrationToken(token, label string, expiresAt time.Time, maxSeats int, metadata map[string]interface{}) error {
|
||||
metadataJSON, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal metadata: %w", err)
|
||||
}
|
||||
|
||||
// Ensure maxSeats is at least 1
|
||||
if maxSeats < 1 {
|
||||
maxSeats = 1
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO registration_tokens (token, label, expires_at, metadata)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
INSERT INTO registration_tokens (token, label, expires_at, max_seats, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`
|
||||
|
||||
_, err = q.db.Exec(query, token, label, expiresAt, metadataJSON)
|
||||
_, err = q.db.Exec(query, token, label, expiresAt, maxSeats, metadataJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create registration token: %w", err)
|
||||
}
|
||||
@@ -67,20 +75,21 @@ func (q *RegistrationTokenQueries) CreateRegistrationToken(token, label string,
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateRegistrationToken checks if a token is valid and unused
|
||||
// ValidateRegistrationToken checks if a token is valid and has available seats
|
||||
func (q *RegistrationTokenQueries) ValidateRegistrationToken(token string) (*RegistrationToken, error) {
|
||||
var regToken RegistrationToken
|
||||
query := `
|
||||
SELECT id, token, label, expires_at, created_at, used_at, used_by_agent_id,
|
||||
revoked, revoked_at, revoked_reason, status, created_by, metadata
|
||||
revoked, revoked_at, revoked_reason, status, created_by, metadata,
|
||||
max_seats, seats_used
|
||||
FROM registration_tokens
|
||||
WHERE token = $1 AND status = 'active' AND expires_at > NOW()
|
||||
WHERE token = $1 AND status = 'active' AND expires_at > NOW() AND seats_used < max_seats
|
||||
`
|
||||
|
||||
err := q.db.Get(®Token, query, token)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("invalid or expired token")
|
||||
return nil, fmt.Errorf("invalid, expired, or seats full")
|
||||
}
|
||||
return nil, fmt.Errorf("failed to validate token: %w", err)
|
||||
}
|
||||
@@ -89,27 +98,19 @@ func (q *RegistrationTokenQueries) ValidateRegistrationToken(token string) (*Reg
|
||||
}
|
||||
|
||||
// MarkTokenUsed marks a token as used by an agent
|
||||
// With seat tracking, this increments seats_used and only marks status='used' when all seats are taken
|
||||
func (q *RegistrationTokenQueries) MarkTokenUsed(token string, agentID uuid.UUID) error {
|
||||
query := `
|
||||
UPDATE registration_tokens
|
||||
SET status = 'used',
|
||||
used_at = NOW(),
|
||||
used_by_agent_id = $1
|
||||
WHERE token = $2 AND status = 'active' AND expires_at > NOW()
|
||||
`
|
||||
// Call the PostgreSQL function that handles seat tracking logic
|
||||
query := `SELECT mark_registration_token_used($1, $2)`
|
||||
|
||||
result, err := q.db.Exec(query, agentID, token)
|
||||
var success bool
|
||||
err := q.db.QueryRow(query, token, agentID).Scan(&success)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to mark token as used: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get rows affected: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("token not found or already used")
|
||||
if !success {
|
||||
return fmt.Errorf("token not found, already used, expired, or seats full")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -120,7 +121,8 @@ func (q *RegistrationTokenQueries) GetActiveRegistrationTokens() ([]Registration
|
||||
var tokens []RegistrationToken
|
||||
query := `
|
||||
SELECT id, token, label, expires_at, created_at, used_at, used_by_agent_id,
|
||||
revoked, revoked_at, revoked_reason, status, created_by, metadata
|
||||
revoked, revoked_at, revoked_reason, status, created_by, metadata,
|
||||
max_seats, seats_used
|
||||
FROM registration_tokens
|
||||
WHERE status = 'active'
|
||||
ORDER BY created_at DESC
|
||||
@@ -139,7 +141,8 @@ func (q *RegistrationTokenQueries) GetAllRegistrationTokens(limit, offset int) (
|
||||
var tokens []RegistrationToken
|
||||
query := `
|
||||
SELECT id, token, label, expires_at, created_at, used_at, used_by_agent_id,
|
||||
revoked, revoked_at, revoked_reason, status, created_by, metadata
|
||||
revoked, revoked_at, revoked_reason, status, created_by, metadata,
|
||||
max_seats, seats_used
|
||||
FROM registration_tokens
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
@@ -153,7 +156,7 @@ func (q *RegistrationTokenQueries) GetAllRegistrationTokens(limit, offset int) (
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
// RevokeRegistrationToken revokes a token
|
||||
// RevokeRegistrationToken revokes a token (can revoke tokens in any status)
|
||||
func (q *RegistrationTokenQueries) RevokeRegistrationToken(token, reason string) error {
|
||||
query := `
|
||||
UPDATE registration_tokens
|
||||
@@ -161,7 +164,7 @@ func (q *RegistrationTokenQueries) RevokeRegistrationToken(token, reason string)
|
||||
revoked = true,
|
||||
revoked_at = NOW(),
|
||||
revoked_reason = $1
|
||||
WHERE token = $2 AND status = 'active'
|
||||
WHERE token = $2
|
||||
`
|
||||
|
||||
result, err := q.db.Exec(query, reason, token)
|
||||
@@ -175,7 +178,28 @@ func (q *RegistrationTokenQueries) RevokeRegistrationToken(token, reason string)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("token not found or already used/revoked")
|
||||
return fmt.Errorf("token not found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteRegistrationToken permanently deletes a token from the database
|
||||
func (q *RegistrationTokenQueries) DeleteRegistrationToken(tokenID uuid.UUID) error {
|
||||
query := `DELETE FROM registration_tokens WHERE id = $1`
|
||||
|
||||
result, err := q.db.Exec(query, tokenID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete token: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get rows affected: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("token not found")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
123
aggregator-server/internal/database/queries/users.go
Normal 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
|
||||
}
|
||||
@@ -68,6 +68,7 @@ type AgentRegistrationRequest struct {
|
||||
OSVersion string `json:"os_version"`
|
||||
OSArchitecture string `json:"os_architecture"`
|
||||
AgentVersion string `json:"agent_version" binding:"required"`
|
||||
RegistrationToken string `json:"registration_token"` // Optional, for fallback method
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
|
||||
|
||||
22
aggregator-server/internal/models/user.go
Normal 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"`
|
||||
}
|
||||
@@ -12,8 +12,8 @@ RUN npm ci
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
# Build the application (skip TypeScript type checking)
|
||||
RUN npx vite build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
@@ -15,7 +15,7 @@ server {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
@@ -26,7 +26,7 @@ server {
|
||||
location /health {
|
||||
proxy_pass http://server:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import History from '@/pages/History';
|
||||
import Settings from '@/pages/Settings';
|
||||
import TokenManagement from '@/pages/TokenManagement';
|
||||
import RateLimiting from '@/pages/RateLimiting';
|
||||
import AgentManagement from '@/pages/settings/AgentManagement';
|
||||
import Login from '@/pages/Login';
|
||||
import Setup from '@/pages/Setup';
|
||||
import { WelcomeChecker } from '@/components/WelcomeChecker';
|
||||
@@ -108,6 +109,7 @@ const App: React.FC = () => {
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/settings/tokens" element={<TokenManagement />} />
|
||||
<Route path="/settings/rate-limiting" element={<RateLimiting />} />
|
||||
<Route path="/settings/agents" element={<AgentManagement />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
|
||||
@@ -16,20 +16,9 @@ export const useHeartbeatStatus = (agentId: string, enabled: boolean = true): Us
|
||||
queryKey: ['heartbeat', agentId],
|
||||
queryFn: () => agentApi.getHeartbeatStatus(agentId),
|
||||
enabled: enabled && !!agentId,
|
||||
staleTime: 5000, // Consider data stale after 5 seconds
|
||||
refetchInterval: (query) => {
|
||||
// Smart polling: only poll when heartbeat is active
|
||||
const data = query.state.data as HeartbeatStatus | undefined;
|
||||
|
||||
// If heartbeat is enabled and still active, poll every 5 seconds
|
||||
if (data?.enabled && data?.active) {
|
||||
return 5000; // 5 seconds
|
||||
}
|
||||
|
||||
// If heartbeat is not active, don't poll
|
||||
return false;
|
||||
},
|
||||
refetchOnWindowFocus: false, // Don't refresh when window gains focus
|
||||
staleTime: 0, // Always consider data stale to force refetch
|
||||
refetchInterval: 5000, // Poll every 5 seconds regardless of state
|
||||
refetchOnWindowFocus: true, // Refresh when window gains focus
|
||||
refetchOnMount: true, // Always refetch when component mounts
|
||||
});
|
||||
};
|
||||
|
||||
@@ -85,6 +85,24 @@ export const useRevokeRegistrationToken = () => {
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteRegistrationToken = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => adminApi.tokens.deleteToken(id),
|
||||
onSuccess: (_, tokenId) => {
|
||||
toast.success('Registration token deleted successfully');
|
||||
queryClient.invalidateQueries({ queryKey: registrationTokenKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: registrationTokenKeys.detail(tokenId) });
|
||||
queryClient.invalidateQueries({ queryKey: registrationTokenKeys.stats() });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error('Failed to delete registration token:', error);
|
||||
toast.error(error.response?.data?.message || 'Failed to delete registration token');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useCleanupRegistrationTokens = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ import {
|
||||
RateLimitSummary
|
||||
} from '@/types';
|
||||
|
||||
// Base URL for API
|
||||
export const API_BASE_URL = (import.meta.env?.VITE_API_URL as string) || '/api/v1';
|
||||
// Base URL for API - use nginx proxy
|
||||
export const API_BASE_URL = '/api/v1';
|
||||
|
||||
// Create axios instance
|
||||
const api = axios.create({
|
||||
@@ -237,8 +237,8 @@ export const logApi = {
|
||||
};
|
||||
|
||||
export const authApi = {
|
||||
// Simple login (using API key or token)
|
||||
login: async (credentials: { token: string }): Promise<{ token: string }> => {
|
||||
// Login with username and password
|
||||
login: async (credentials: { username: string; password: string }): Promise<{ token: string; user: any }> => {
|
||||
const response = await api.post('/auth/login', credentials);
|
||||
return response.data;
|
||||
},
|
||||
@@ -255,9 +255,9 @@ export const authApi = {
|
||||
},
|
||||
};
|
||||
|
||||
// Setup API for server configuration (uses base API without auth)
|
||||
// Setup API for server configuration (uses nginx proxy)
|
||||
const setupApiInstance = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
baseURL: '/api',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -283,8 +283,8 @@ export const setupApi = {
|
||||
serverHost: string;
|
||||
serverPort: string;
|
||||
maxSeats: string;
|
||||
}): Promise<{ message: string; configPath?: string; restart?: boolean }> => {
|
||||
const response = await setupApiInstance.post('/setup', config);
|
||||
}): Promise<{ message: string; jwtSecret?: string; envContent?: string; manualRestartRequired?: boolean; manualRestartCommand?: string; configFilePath?: string }> => {
|
||||
const response = await setupApiInstance.post('/setup/configure', config);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -456,11 +456,16 @@ export const adminApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Revoke registration token
|
||||
// Revoke registration token (soft delete)
|
||||
revokeToken: async (id: string): Promise<void> => {
|
||||
await api.delete(`/admin/registration-tokens/${id}`);
|
||||
},
|
||||
|
||||
// Delete registration token (hard delete)
|
||||
deleteToken: async (id: string): Promise<void> => {
|
||||
await api.delete(`/admin/registration-tokens/delete/${id}`);
|
||||
},
|
||||
|
||||
// Get registration token statistics
|
||||
getStats: async (): Promise<RegistrationTokenStats> => {
|
||||
const response = await api.get('/admin/registration-tokens/stats');
|
||||
@@ -479,7 +484,17 @@ export const adminApi = {
|
||||
// Get all rate limit configurations
|
||||
getConfigs: async (): Promise<RateLimitConfig[]> => {
|
||||
const response = await api.get('/admin/rate-limits');
|
||||
return response.data;
|
||||
|
||||
// Backend returns { settings: {...}, updated_at: "..." }
|
||||
// Transform settings object to array format expected by frontend
|
||||
const settings = response.data.settings || {};
|
||||
const configs: RateLimitConfig[] = Object.entries(settings).map(([endpoint, config]: [string, any]) => ({
|
||||
...config,
|
||||
endpoint,
|
||||
updated_at: response.data.updated_at, // Preserve update timestamp
|
||||
}));
|
||||
|
||||
return configs;
|
||||
},
|
||||
|
||||
// Update rate limit configuration
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Eye, EyeOff, Shield } from 'lucide-react';
|
||||
import { Eye, EyeOff, Shield, User } from 'lucide-react';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { authApi } from '@/lib/api';
|
||||
import { handleApiError } from '@/lib/api';
|
||||
@@ -9,24 +9,31 @@ import toast from 'react-hot-toast';
|
||||
const Login: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { setToken } = useAuthStore();
|
||||
const [token, setTokenInput] = useState('');
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!token.trim()) {
|
||||
toast.error('Please enter your authentication token');
|
||||
if (!username.trim()) {
|
||||
toast.error('Please enter your username');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password.trim()) {
|
||||
toast.error('Please enter your password');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await authApi.login({ token: token.trim() });
|
||||
const response = await authApi.login({ username: username.trim(), password: password.trim() });
|
||||
setToken(response.token);
|
||||
localStorage.setItem('auth_token', response.token);
|
||||
toast.success('Login successful');
|
||||
localStorage.setItem('user', JSON.stringify(response.user));
|
||||
toast.success(`Welcome back, ${response.user.username}!`);
|
||||
navigate('/');
|
||||
} catch (error) {
|
||||
const apiError = handleApiError(error);
|
||||
@@ -48,7 +55,7 @@ const Login: React.FC = () => {
|
||||
Sign in to RedFlag
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Enter your authentication token to access the dashboard
|
||||
Enter your username and password to access the dashboard
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -56,25 +63,45 @@ const Login: React.FC = () => {
|
||||
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="token" className="block text-sm font-medium text-gray-700">
|
||||
Authentication Token
|
||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
|
||||
Username
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<User className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="appearance-none block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
placeholder="Enter your username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
Password
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<input
|
||||
id="token"
|
||||
type={showToken ? 'text' : 'password'}
|
||||
value={token}
|
||||
onChange={(e) => setTokenInput(e.target.value)}
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
placeholder="Enter your JWT token"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showToken ? (
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5 text-gray-400" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5 text-gray-400" />
|
||||
@@ -106,11 +133,11 @@ const Login: React.FC = () => {
|
||||
<div className="flex items-start space-x-2">
|
||||
<Shield className="h-4 w-4 text-gray-400 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">How to get your token:</p>
|
||||
<p className="font-medium">Login credentials:</p>
|
||||
<ul className="mt-1 list-disc list-inside space-y-1 text-xs">
|
||||
<li>Check your RedFlag server configuration</li>
|
||||
<li>Look for the JWT secret in your server settings</li>
|
||||
<li>Generate a token using the server CLI</li>
|
||||
<li>Use the admin username you configured during server setup</li>
|
||||
<li>Enter the password you set during server configuration</li>
|
||||
<li>If you forgot your credentials, check your server configuration</li>
|
||||
<li>Contact your administrator if you need access</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Shield,
|
||||
RefreshCw,
|
||||
@@ -25,7 +26,24 @@ import {
|
||||
} from '../hooks/useRateLimits';
|
||||
import { RateLimitConfig, RateLimitStats, RateLimitUsage } from '@/types';
|
||||
|
||||
// Helper function to format date/time strings
|
||||
const formatDateTime = (dateString: string): string => {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
} catch (error) {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
const RateLimiting: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [editingMode, setEditingMode] = useState(false);
|
||||
const [editingConfigs, setEditingConfigs] = useState<RateLimitConfig[]>([]);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
@@ -46,14 +64,14 @@ const RateLimiting: React.FC = () => {
|
||||
const cleanupLimits = useCleanupRateLimits();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (configs) {
|
||||
if (configs && Array.isArray(configs)) {
|
||||
setEditingConfigs([...configs]);
|
||||
}
|
||||
}, [configs]);
|
||||
|
||||
// Filtered configurations for display
|
||||
const filteredConfigs = useMemo(() => {
|
||||
if (!configs) return [];
|
||||
if (!configs || !Array.isArray(configs)) return [];
|
||||
|
||||
return configs.filter((config) => {
|
||||
const matchesSearch = searchTerm === '' ||
|
||||
@@ -122,6 +140,13 @@ const RateLimiting: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-6 py-8">
|
||||
<button
|
||||
onClick={() => navigate('/settings')}
|
||||
className="text-sm text-gray-500 hover:text-gray-700 mb-4"
|
||||
>
|
||||
← Back to Settings
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -234,7 +259,9 @@ const RateLimiting: React.FC = () => {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingConfigs([...configs!]);
|
||||
if (configs && Array.isArray(configs)) {
|
||||
setEditingConfigs([...configs]);
|
||||
}
|
||||
setEditingMode(false);
|
||||
}}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300"
|
||||
@@ -489,7 +516,7 @@ const RateLimiting: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Rate Limit Statistics */}
|
||||
{stats && stats.length > 0 && (
|
||||
{stats && Array.isArray(stats) && stats.length > 0 && (
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Rate Limit Statistics</h2>
|
||||
@@ -526,7 +553,7 @@ const RateLimiting: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stat.top_clients && stat.top_clients.length > 0 && (
|
||||
{stat.top_clients && Array.isArray(stat.top_clients) && stat.top_clients.length > 0 && (
|
||||
<div className="mt-4 pt-3 border-t border-gray-200">
|
||||
<p className="text-xs text-gray-600 mb-2">Top Clients:</p>
|
||||
<div className="space-y-1">
|
||||
@@ -547,7 +574,7 @@ const RateLimiting: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* Usage Monitoring */}
|
||||
{usage && usage.length > 0 && (
|
||||
{usage && Array.isArray(usage) && usage.length > 0 && (
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Usage Monitoring</h2>
|
||||
|
||||
@@ -83,11 +83,11 @@ const Settings: React.FC = () => {
|
||||
},
|
||||
{
|
||||
title: 'Agent Management',
|
||||
description: 'Agent defaults and cleanup policies',
|
||||
description: 'Deploy and configure agents across platforms',
|
||||
icon: SettingsIcon,
|
||||
href: '/settings/agents',
|
||||
stats: null,
|
||||
status: 'not-implemented'
|
||||
status: 'implemented'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -134,14 +134,17 @@ const Settings: React.FC = () => {
|
||||
<p className="text-sm text-gray-400 mt-1">Coming soon</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-gray-50 border border-gray-200 rounded-lg opacity-60">
|
||||
<Link
|
||||
to="/settings/agents"
|
||||
className="block p-6 bg-white border border-gray-200 rounded-lg hover:border-purple-300 hover:shadow-sm transition-all"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<SettingsIcon className="w-8 h-8 text-gray-400" />
|
||||
<ArrowRight className="w-5 h-5 text-gray-300" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-500">Agent Management</h3>
|
||||
<p className="text-sm text-gray-400 mt-1">Coming soon</p>
|
||||
<SettingsIcon className="w-8 h-8 text-purple-600" />
|
||||
<ArrowRight className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900">Agent Management</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">Deploy and configure agents</p>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Overview Statistics */}
|
||||
@@ -326,7 +329,6 @@ const Settings: React.FC = () => {
|
||||
<h3 className="font-medium text-yellow-800 mb-3">🚧 Planned Features</h3>
|
||||
<ul className="space-y-1 text-sm text-yellow-700">
|
||||
<li>• System configuration management</li>
|
||||
<li>• Agent management settings</li>
|
||||
<li>• Integration with third-party services</li>
|
||||
<li>• Persistent settings storage</li>
|
||||
</ul>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { XCircle } from 'lucide-react';
|
||||
import { Settings, Database, User, Shield, Eye, EyeOff, CheckCircle } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { setupApi } from '@/lib/api';
|
||||
|
||||
@@ -17,11 +17,15 @@ interface SetupFormData {
|
||||
maxSeats: string;
|
||||
}
|
||||
|
||||
|
||||
const Setup: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [jwtSecret, setJwtSecret] = useState<string | null>(null);
|
||||
const [envContent, setEnvContent] = useState<string | null>(null);
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showDbPassword, setShowDbPassword] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState<SetupFormData>({
|
||||
adminUser: 'admin',
|
||||
@@ -108,20 +112,12 @@ const Setup: React.FC = () => {
|
||||
try {
|
||||
const result = await setupApi.configure(formData);
|
||||
|
||||
// Store JWT secret, env content and show success screen
|
||||
setJwtSecret(result.jwtSecret || null);
|
||||
setEnvContent(result.envContent || null);
|
||||
setShowSuccess(true);
|
||||
toast.success(result.message || 'Configuration saved successfully!');
|
||||
|
||||
if (result.restart) {
|
||||
// Server is restarting, wait for it to come back online
|
||||
setTimeout(() => {
|
||||
navigate('/login');
|
||||
}, 5000); // Give server time to restart
|
||||
} else {
|
||||
// No restart, redirect immediately
|
||||
setTimeout(() => {
|
||||
navigate('/login');
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Setup error:', error);
|
||||
const errorMessage = error.response?.data?.error || error.message || 'Setup failed';
|
||||
@@ -132,38 +128,177 @@ const Setup: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Success screen with credentials display
|
||||
if (showSuccess && jwtSecret) {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="py-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Server Setup</h2>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Configure your update management server
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 text-center mb-2">
|
||||
Configuration Complete!
|
||||
</h1>
|
||||
<p className="text-gray-600 text-center">
|
||||
Your RedFlag server is ready to use
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<form onSubmit={handleSubmit} className="divide-y divide-gray-200">
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="px-6 py-4 bg-red-50">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<XCircle className="h-5 w-5 text-red-400" />
|
||||
{/* Success Card */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
|
||||
{/* Admin Credentials Section */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">Administrator Credentials</h3>
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-md p-4">
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Username</label>
|
||||
<div className="mt-1 p-2 bg-white border border-gray-300 rounded text-sm font-mono">{formData.adminUser}</div>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">{error}</h3>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Password</label>
|
||||
<div className="mt-1 p-2 bg-white border border-gray-300 rounded text-sm font-mono">••••••••</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 p-3 bg-green-50 border border-green-200 rounded-md">
|
||||
<p className="text-sm text-green-800">
|
||||
<strong>Important:</strong> Save these credentials securely. You'll use them to login to the RedFlag dashboard.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration Content Section */}
|
||||
{envContent && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">Configuration File Content</h3>
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-md p-4">
|
||||
<textarea
|
||||
readOnly
|
||||
value={envContent}
|
||||
className="w-full h-64 p-3 text-xs font-mono text-gray-800 bg-white border border-gray-300 rounded-md resize-none focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(envContent);
|
||||
toast.success('Configuration content copied to clipboard!');
|
||||
}}
|
||||
className="mt-3 w-full flex justify-center py-2 px-4 border border-transparent rounded-md text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
|
||||
>
|
||||
Copy Configuration Content
|
||||
</button>
|
||||
<div className="mt-3 p-3 bg-blue-50 border border-blue-200 rounded-md">
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>Important:</strong> Copy this configuration content and save it to <code className="bg-blue-100 px-1 rounded">./config/.env</code>, then run <code className="bg-blue-100 px-1 rounded">docker-compose down && docker-compose up -d</code> to apply the configuration.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Admin Account */}
|
||||
<div className="px-6 py-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Admin Account</h3>
|
||||
{/* JWT Secret Section (Server Configuration) */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">Server JWT Secret</h3>
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-md p-4">
|
||||
<code className="text-sm text-gray-800 break-all font-mono">{jwtSecret}</code>
|
||||
</div>
|
||||
<div className="mt-3 p-3 bg-gray-50 border border-gray-200 rounded-md">
|
||||
<p className="text-sm text-gray-700">
|
||||
<strong>For your information:</strong> This JWT secret is used internally by the server for session management and agent authentication. It's automatically included in the configuration file above.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(jwtSecret);
|
||||
toast.success('JWT secret copied to clipboard!');
|
||||
}}
|
||||
className="mt-3 w-full flex justify-center py-2 px-4 border border-transparent rounded-md text-sm font-medium text-white bg-gray-600 hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
|
||||
>
|
||||
Copy JWT Secret (Optional)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Next Steps */}
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">Next Steps</h3>
|
||||
<ol className="list-decimal list-inside space-y-2 text-sm text-gray-600">
|
||||
<li>Copy the configuration content using the green button above</li>
|
||||
<li>Save it to <code className="bg-gray-100 px-1 rounded">./config/.env</code></li>
|
||||
<li>Run <code className="bg-gray-100 px-1 rounded">docker-compose down && docker-compose up -d</code></li>
|
||||
<li>Login to the dashboard with your admin username and password</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-gray-200 space-y-3">
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-md">
|
||||
<p className="text-sm text-yellow-800">
|
||||
<strong>Important:</strong> You must restart the containers to apply the configuration before logging in.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
toast.success('Please run: docker-compose down && docker-compose up -d');
|
||||
}}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md text-sm font-medium text-white bg-yellow-600 hover:bg-yellow-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yellow-500"
|
||||
>
|
||||
Show Restart Command
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setTimeout(() => navigate('/login'), 500);
|
||||
}}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
Continue to Login (After Restart)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<div className="w-16 h-16 bg-indigo-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-2xl">🚩</span>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 text-center mb-2">
|
||||
Configure RedFlag Server
|
||||
</h1>
|
||||
<p className="text-gray-600 text-center">
|
||||
Set up your update management server configuration
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Setup Form */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<div className="text-sm text-red-800">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Administrator Account */}
|
||||
<div>
|
||||
<div className="flex items-center mb-4">
|
||||
<User className="h-5 w-5 text-indigo-600 mr-2" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Administrator Account</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="adminUser" className="block text-sm font-medium text-gray-700">
|
||||
<label htmlFor="adminUser" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Admin Username
|
||||
</label>
|
||||
<input
|
||||
@@ -172,33 +307,51 @@ const Setup: React.FC = () => {
|
||||
name="adminUser"
|
||||
value={formData.adminUser}
|
||||
onChange={handleInputChange}
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="admin"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="adminPassword" className="block text-sm font-medium text-gray-700">
|
||||
<label htmlFor="adminPassword" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Admin Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="adminPassword"
|
||||
name="adminPassword"
|
||||
value={formData.adminPassword}
|
||||
onChange={handleInputChange}
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
className="block w-full px-3 py-2 pr-10 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="Enter secure password"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Database Configuration */}
|
||||
<div className="px-6 py-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Database Configuration</h3>
|
||||
<div>
|
||||
<div className="flex items-center mb-4">
|
||||
<Database className="h-5 w-5 text-indigo-600 mr-2" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Database Configuration</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<label htmlFor="dbHost" className="block text-sm font-medium text-gray-700">
|
||||
<label htmlFor="dbHost" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Database Host
|
||||
</label>
|
||||
<input
|
||||
@@ -207,12 +360,13 @@ const Setup: React.FC = () => {
|
||||
name="dbHost"
|
||||
value={formData.dbHost}
|
||||
onChange={handleInputChange}
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="postgres"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="dbPort" className="block text-sm font-medium text-gray-700">
|
||||
<label htmlFor="dbPort" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Database Port
|
||||
</label>
|
||||
<input
|
||||
@@ -221,12 +375,12 @@ const Setup: React.FC = () => {
|
||||
name="dbPort"
|
||||
value={formData.dbPort}
|
||||
onChange={handleInputChange}
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="dbName" className="block text-sm font-medium text-gray-700">
|
||||
<label htmlFor="dbName" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Database Name
|
||||
</label>
|
||||
<input
|
||||
@@ -235,12 +389,13 @@ const Setup: React.FC = () => {
|
||||
name="dbName"
|
||||
value={formData.dbName}
|
||||
onChange={handleInputChange}
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="redflag"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="dbUser" className="block text-sm font-medium text-gray-700">
|
||||
<label htmlFor="dbUser" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Database User
|
||||
</label>
|
||||
<input
|
||||
@@ -249,33 +404,51 @@ const Setup: React.FC = () => {
|
||||
name="dbUser"
|
||||
value={formData.dbUser}
|
||||
onChange={handleInputChange}
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="redflag"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="dbPassword" className="block text-sm font-medium text-gray-700">
|
||||
<label htmlFor="dbPassword" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Database Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="password"
|
||||
type={showDbPassword ? 'text' : 'password'}
|
||||
id="dbPassword"
|
||||
name="dbPassword"
|
||||
value={formData.dbPassword}
|
||||
onChange={handleInputChange}
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
className="block w-full px-3 py-2 pr-10 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="Enter database password"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowDbPassword(!showDbPassword)}
|
||||
>
|
||||
{showDbPassword ? (
|
||||
<EyeOff className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Server Configuration */}
|
||||
<div className="px-6 py-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Server Configuration</h3>
|
||||
<div>
|
||||
<div className="flex items-center mb-4">
|
||||
<Settings className="h-5 w-5 text-indigo-600 mr-2" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Server Configuration</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="serverHost" className="block text-sm font-medium text-gray-700">
|
||||
<label htmlFor="serverHost" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Server Host
|
||||
</label>
|
||||
<input
|
||||
@@ -284,12 +457,13 @@ const Setup: React.FC = () => {
|
||||
name="serverHost"
|
||||
value={formData.serverHost}
|
||||
onChange={handleInputChange}
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="0.0.0.0"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="serverPort" className="block text-sm font-medium text-gray-700">
|
||||
<label htmlFor="serverPort" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Server Port
|
||||
</label>
|
||||
<input
|
||||
@@ -298,12 +472,13 @@ const Setup: React.FC = () => {
|
||||
name="serverPort"
|
||||
value={formData.serverPort}
|
||||
onChange={handleInputChange}
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="8080"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="maxSeats" className="block text-sm font-medium text-gray-700">
|
||||
<label htmlFor="maxSeats" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Maximum Agent Seats
|
||||
</label>
|
||||
<input
|
||||
@@ -312,9 +487,10 @@ const Setup: React.FC = () => {
|
||||
name="maxSeats"
|
||||
value={formData.maxSeats}
|
||||
onChange={handleInputChange}
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
min="1"
|
||||
max="1000"
|
||||
placeholder="50"
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">Security limit for agent registration</p>
|
||||
@@ -323,19 +499,22 @@ const Setup: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="px-6 py-4 bg-gray-50">
|
||||
<div className="pt-6 border-t border-gray-200">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Configuring...
|
||||
Configuring RedFlag Server...
|
||||
</div>
|
||||
) : (
|
||||
'Configure Server'
|
||||
<div className="flex items-center">
|
||||
<Shield className="w-4 h-4 mr-2" />
|
||||
Configure RedFlag Server
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Shield,
|
||||
Plus,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
useRegistrationTokens,
|
||||
useCreateRegistrationToken,
|
||||
useRevokeRegistrationToken,
|
||||
useDeleteRegistrationToken,
|
||||
useRegistrationTokenStats,
|
||||
useCleanupRegistrationTokens
|
||||
} from '../hooks/useRegistrationTokens';
|
||||
@@ -26,6 +28,8 @@ import { RegistrationToken, CreateRegistrationTokenRequest } from '@/types';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
const TokenManagement: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Filters and search
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'used' | 'expired' | 'revoked'>('all');
|
||||
@@ -47,6 +51,7 @@ const TokenManagement: React.FC = () => {
|
||||
const { data: stats, isLoading: isLoadingStats } = useRegistrationTokenStats();
|
||||
const createToken = useCreateRegistrationToken();
|
||||
const revokeToken = useRevokeRegistrationToken();
|
||||
const deleteToken = useDeleteRegistrationToken();
|
||||
const cleanupTokens = useCleanupRegistrationTokens();
|
||||
|
||||
// Reset page when filters change
|
||||
@@ -57,15 +62,15 @@ const TokenManagement: React.FC = () => {
|
||||
// Form state
|
||||
const [formData, setFormData] = useState<CreateRegistrationTokenRequest>({
|
||||
label: '',
|
||||
max_seats: 10,
|
||||
expires_at: '',
|
||||
expires_in: '168h', // Default 7 days
|
||||
max_seats: 1, // Default 1 seat
|
||||
});
|
||||
|
||||
const handleCreateToken = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
createToken.mutate(formData, {
|
||||
onSuccess: () => {
|
||||
setFormData({ label: '', max_seats: 10, expires_at: '' });
|
||||
setFormData({ label: '', expires_in: '168h', max_seats: 1 });
|
||||
setShowCreateForm(false);
|
||||
refetch();
|
||||
},
|
||||
@@ -78,44 +83,65 @@ const TokenManagement: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteToken = (tokenId: string, tokenLabel: string) => {
|
||||
if (confirm(`⚠️ PERMANENTLY DELETE token "${tokenLabel}"? This cannot be undone!`)) {
|
||||
deleteToken.mutate(tokenId, { onSuccess: () => refetch() });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCleanup = () => {
|
||||
if (confirm('Clean up all expired tokens? This cannot be undone.')) {
|
||||
cleanupTokens.mutate(undefined, { onSuccess: () => refetch() });
|
||||
}
|
||||
};
|
||||
|
||||
const getServerUrl = () => {
|
||||
return `${window.location.protocol}//${window.location.host}`;
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
// Show success feedback
|
||||
};
|
||||
|
||||
const copyInstallCommand = async (token: string) => {
|
||||
const command = `curl -sSL https://get.redflag.dev | bash -s -- ${token}`;
|
||||
const serverUrl = getServerUrl();
|
||||
const command = `curl -sfL ${serverUrl}/api/v1/install/linux | bash -s -- ${token}`;
|
||||
await navigator.clipboard.writeText(command);
|
||||
};
|
||||
|
||||
const generateInstallCommand = (token: string) => {
|
||||
return `curl -sSL https://get.redflag.dev | bash -s -- ${token}`;
|
||||
const serverUrl = getServerUrl();
|
||||
return `curl -sfL ${serverUrl}/api/v1/install/linux | bash -s -- ${token}`;
|
||||
};
|
||||
|
||||
const getStatusColor = (token: RegistrationToken) => {
|
||||
if (!token.is_active) return 'text-gray-500';
|
||||
if (token.expires_at && new Date(token.expires_at) < new Date()) return 'text-red-600';
|
||||
if (token.max_seats && token.current_seats >= token.max_seats) return 'text-yellow-600';
|
||||
return 'text-green-600';
|
||||
if (token.status === 'revoked') return 'text-gray-500';
|
||||
if (token.status === 'expired') return 'text-red-600';
|
||||
if (token.status === 'used') return 'text-yellow-600';
|
||||
if (token.status === 'active') return 'text-green-600';
|
||||
return 'text-gray-500';
|
||||
};
|
||||
|
||||
const getStatusText = (token: RegistrationToken) => {
|
||||
if (!token.is_active) return 'Revoked';
|
||||
if (token.expires_at && new Date(token.expires_at) < new Date()) return 'Expired';
|
||||
if (token.max_seats && token.current_seats >= token.max_seats) return 'Full';
|
||||
return 'Active';
|
||||
if (token.status === 'revoked') return 'Revoked';
|
||||
if (token.status === 'expired') return 'Expired';
|
||||
if (token.status === 'used') return 'Used';
|
||||
if (token.status === 'active') return 'Active';
|
||||
return token.status.charAt(0).toUpperCase() + token.status.slice(1);
|
||||
};
|
||||
|
||||
const filteredTokens = tokensData?.tokens || [];
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-6 py-8">
|
||||
<button
|
||||
onClick={() => navigate('/settings')}
|
||||
className="text-sm text-gray-500 hover:text-gray-700 mb-4"
|
||||
>
|
||||
← Back to Settings
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -220,32 +246,37 @@ const TokenManagement: React.FC = () => {
|
||||
required
|
||||
value={formData.label}
|
||||
onChange={(e) => setFormData({ ...formData, label: e.target.value })}
|
||||
placeholder="e.g., Production Team"
|
||||
placeholder="e.g., Production Servers, Development Team"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Max Seats</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Expires In</label>
|
||||
<select
|
||||
value={formData.expires_in}
|
||||
onChange={(e) => setFormData({ ...formData, expires_in: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="24h">24 hours</option>
|
||||
<option value="72h">3 days</option>
|
||||
<option value="168h">7 days (1 week)</option>
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500">Maximum 7 days per server security policy</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Max Seats (Agents)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={formData.max_seats}
|
||||
onChange={(e) => setFormData({ ...formData, max_seats: e.target.value ? parseInt(e.target.value) : undefined })}
|
||||
placeholder="Leave empty for unlimited"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Expiration Date</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={formData.expires_at}
|
||||
onChange={(e) => setFormData({ ...formData, expires_at: e.target.value })}
|
||||
min={new Date().toISOString().slice(0, 16)}
|
||||
max="100"
|
||||
value={formData.max_seats || 1}
|
||||
onChange={(e) => setFormData({ ...formData, max_seats: parseInt(e.target.value) || 1 })}
|
||||
placeholder="1"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">Number of agents that can use this token</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -403,27 +434,24 @@ const TokenManagement: React.FC = () => {
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">
|
||||
{token.current_seats}
|
||||
{token.max_seats && ` / ${token.max_seats}`}
|
||||
</div>
|
||||
{token.max_seats && (
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mt-1">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full"
|
||||
style={{ width: `${Math.min((token.current_seats / token.max_seats) * 100, 100)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{token.seats_used}/{token.max_seats} used
|
||||
{token.seats_used >= token.max_seats && (
|
||||
<span className="ml-2 text-xs text-red-600">(Full)</span>
|
||||
)}
|
||||
{token.seats_used < token.max_seats && token.status === 'active' && (
|
||||
<span className="ml-2 text-xs text-green-600">({token.max_seats - token.seats_used} available)</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{formatDateTime(token.created_at)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{formatDateTime(token.expires_at) || 'Never'}
|
||||
{formatDateTime(token.expires_at)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{formatDateTime(token.last_used_at) || 'Never'}
|
||||
{token.used_at ? formatDateTime(token.used_at) : 'Never'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -441,16 +469,24 @@ const TokenManagement: React.FC = () => {
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
{token.is_active && (
|
||||
{token.status === 'active' && (
|
||||
<button
|
||||
onClick={() => handleRevokeToken(token.id, token.label)}
|
||||
onClick={() => handleRevokeToken(token.id, token.label || 'this token')}
|
||||
disabled={revokeToken.isPending}
|
||||
className="text-orange-600 hover:text-orange-800 disabled:opacity-50"
|
||||
title="Revoke token (soft delete)"
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDeleteToken(token.id, token.label || 'this token')}
|
||||
disabled={deleteToken.isPending}
|
||||
className="text-red-600 hover:text-red-800 disabled:opacity-50"
|
||||
title="Revoke token"
|
||||
title="Permanently delete token"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
491
aggregator-web/src/pages/settings/AgentManagement.tsx
Normal 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;
|
||||
@@ -292,20 +292,24 @@ export interface ApiError {
|
||||
export interface RegistrationToken {
|
||||
id: string;
|
||||
token: string;
|
||||
label: string;
|
||||
expires_at: string | null;
|
||||
max_seats: number | null;
|
||||
current_seats: number;
|
||||
is_active: boolean;
|
||||
label: string | null;
|
||||
expires_at: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
last_used_at: string | null;
|
||||
used_at: string | null;
|
||||
used_by_agent_id: string | null;
|
||||
revoked: boolean;
|
||||
revoked_at: string | null;
|
||||
revoked_reason: string | null;
|
||||
status: 'active' | 'used' | 'expired' | 'revoked';
|
||||
created_by: string;
|
||||
metadata: Record<string, any>;
|
||||
max_seats: number;
|
||||
seats_used: number;
|
||||
}
|
||||
|
||||
export interface CreateRegistrationTokenRequest {
|
||||
label: string;
|
||||
expires_at?: string;
|
||||
expires_in?: string;
|
||||
max_seats?: number;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ services:
|
||||
environment:
|
||||
POSTGRES_DB: redflag
|
||||
POSTGRES_USER: redflag
|
||||
POSTGRES_PASSWORD: redflag
|
||||
POSTGRES_PASSWORD: redflag_bootstrap
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --lc-collate=C --lc-ctype=C"
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
- ./config/.env:/shared/.env
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
@@ -16,31 +18,27 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ./config/.env
|
||||
|
||||
server:
|
||||
build:
|
||||
context: ./aggregator-server
|
||||
dockerfile: Dockerfile
|
||||
context: .
|
||||
dockerfile: ./aggregator-server/Dockerfile
|
||||
container_name: redflag-server
|
||||
volumes:
|
||||
- ./aggregator-agent/redflag-agent:/app/redflag-agent:ro
|
||||
- server-config:/app/config
|
||||
- server-data:/app/data
|
||||
- ./config/.env:/shared/.env
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
- REDFLAG_DB_HOST=postgres
|
||||
- REDFLAG_DB_PORT=5432
|
||||
- REDFLAG_DB_NAME=redflag
|
||||
- REDFLAG_DB_USER=redflag
|
||||
- REDFLAG_DB_PASSWORD=redflag
|
||||
- REDFLAG_SERVER_HOST=0.0.0.0
|
||||
- REDFLAG_SERVER_PORT=8080
|
||||
command: ["./redflag-server"]
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ./config/.env
|
||||
|
||||
web:
|
||||
build:
|
||||
|
||||