Compare commits
15 Commits
bd1d1c2c7c
...
self-daemo
Author | SHA1 | Date | |
---|---|---|---|
64680a6ece | |||
4c3fcbc45a | |||
a41e0d625c | |||
377af163f0 | |||
852275945c | |||
c7dcebb894 | |||
2431d3cfb0 | |||
b4f57b3604 | |||
e6d87d025f | |||
3aff0ab5ef | |||
8d6a280441 | |||
aae9022bb2 | |||
c3ded9bfd2 | |||
95a5b880d7 | |||
16c0766a15 |
33
--help
33
--help
@@ -1,33 +0,0 @@
|
||||
node_id: GALACTICA
|
||||
bind_address: 127.0.0.1
|
||||
port: 8080
|
||||
data_dir: ./data
|
||||
seed_nodes: []
|
||||
read_only: false
|
||||
log_level: info
|
||||
gossip_interval_min: 60
|
||||
gossip_interval_max: 120
|
||||
sync_interval: 300
|
||||
catchup_interval: 120
|
||||
bootstrap_max_age_hours: 720
|
||||
throttle_delay_ms: 100
|
||||
fetch_delay_ms: 50
|
||||
compression_enabled: true
|
||||
compression_level: 3
|
||||
default_ttl: "0"
|
||||
max_json_size: 1048576
|
||||
rate_limit_requests: 100
|
||||
rate_limit_window: 1m
|
||||
tamper_log_actions:
|
||||
- data_write
|
||||
- user_create
|
||||
- auth_failure
|
||||
backup_enabled: true
|
||||
backup_schedule: 0 0 * * *
|
||||
backup_path: ./backups
|
||||
backup_retention: 7
|
||||
auth_enabled: true
|
||||
tamper_logging_enabled: true
|
||||
clustering_enabled: true
|
||||
rate_limiting_enabled: true
|
||||
revision_history_enabled: true
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,6 +1,8 @@
|
||||
.claude/
|
||||
.kvs/
|
||||
data/
|
||||
data*/
|
||||
integration_test/
|
||||
*.yaml
|
||||
!config.yaml
|
||||
kvs
|
||||
|
211
CLAUDE.md
Normal file
211
CLAUDE.md
Normal file
@@ -0,0 +1,211 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands for Development
|
||||
|
||||
### Build and Test Commands
|
||||
```bash
|
||||
# Build the binary
|
||||
go build -o kvs .
|
||||
|
||||
# Run with default config (auto-generates config.yaml)
|
||||
./kvs start config.yaml
|
||||
|
||||
# Run with custom config
|
||||
./kvs start /path/to/config.yaml
|
||||
|
||||
# Check running instances
|
||||
./kvs status
|
||||
|
||||
# Stop instance
|
||||
./kvs stop config
|
||||
|
||||
# Run comprehensive integration tests
|
||||
./integration_test.sh
|
||||
|
||||
# Create test conflict data for debugging
|
||||
go run test_conflict.go data1 data2
|
||||
|
||||
# Build and test in one go
|
||||
go build -o kvs . && ./integration_test.sh
|
||||
```
|
||||
|
||||
### Process Management Commands
|
||||
```bash
|
||||
# Start as background daemon
|
||||
./kvs start <config.yaml> # .yaml extension optional
|
||||
|
||||
# Stop daemon
|
||||
./kvs stop <config> # Graceful SIGTERM shutdown
|
||||
|
||||
# Restart daemon
|
||||
./kvs restart <config> # Stop then start
|
||||
|
||||
# Show status
|
||||
./kvs status # All instances
|
||||
./kvs status <config> # Specific instance
|
||||
|
||||
# Run in foreground (for debugging)
|
||||
./kvs <config.yaml> # Logs to stdout, blocks terminal
|
||||
|
||||
# View daemon logs
|
||||
tail -f ~/.kvs/logs/kvs_<config>.yaml.log
|
||||
|
||||
# Global state directories
|
||||
~/.kvs/pids/ # PID files (works from any directory)
|
||||
~/.kvs/logs/ # Daemon log files
|
||||
```
|
||||
|
||||
### Development Workflow
|
||||
```bash
|
||||
# Format and check code
|
||||
go fmt ./...
|
||||
go vet ./...
|
||||
|
||||
# Run dependencies management
|
||||
go mod tidy
|
||||
|
||||
# Check build without artifacts
|
||||
go build .
|
||||
|
||||
# Test specific cluster scenarios
|
||||
./kvs start node1.yaml
|
||||
./kvs start node2.yaml
|
||||
|
||||
# Wait for cluster formation
|
||||
sleep 5
|
||||
|
||||
# Test data operations
|
||||
curl -X PUT http://localhost:8081/kv/test/data -H "Content-Type: application/json" -d '{"test":"data"}'
|
||||
curl http://localhost:8082/kv/test/data # Should replicate within ~30 seconds
|
||||
|
||||
# Check daemon status
|
||||
./kvs status
|
||||
|
||||
# View logs
|
||||
tail -f ~/.kvs/logs/kvs_node1.yaml.log
|
||||
|
||||
# Cleanup
|
||||
./kvs stop node1
|
||||
./kvs stop node2
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### High-Level Structure
|
||||
KVS is a **distributed, eventually consistent key-value store** built around three core systems:
|
||||
|
||||
1. **Gossip Protocol** (`cluster/gossip.go`) - Decentralized membership management and failure detection
|
||||
2. **Merkle Tree Sync** (`cluster/sync.go`, `cluster/merkle.go`) - Efficient data synchronization and conflict resolution
|
||||
3. **Modular Server** (`server/`) - HTTP API with pluggable feature modules
|
||||
|
||||
### Key Architectural Patterns
|
||||
|
||||
#### Modular Package Design
|
||||
- **`auth/`** - Complete JWT authentication system with POSIX-inspired permissions
|
||||
- **`cluster/`** - Distributed systems logic (gossip, sync, merkle trees)
|
||||
- **`daemon/`** - Process management (daemonization, PID files, lifecycle)
|
||||
- **`storage/`** - BadgerDB abstraction with compression and revision history
|
||||
- **`server/`** - HTTP handlers, routing, and lifecycle management
|
||||
- **`features/`** - Utility functions for TTL, rate limiting, tamper logging, backup
|
||||
- **`types/`** - Centralized type definitions for all components
|
||||
- **`config/`** - Configuration loading with auto-generation
|
||||
- **`utils/`** - Cryptographic hashing utilities
|
||||
|
||||
#### Core Data Model
|
||||
```go
|
||||
// Primary storage format
|
||||
type StoredValue struct {
|
||||
UUID string `json:"uuid"` // Unique version identifier
|
||||
Timestamp int64 `json:"timestamp"` // Unix timestamp (milliseconds)
|
||||
Data json.RawMessage `json:"data"` // Actual user JSON payload
|
||||
}
|
||||
```
|
||||
|
||||
#### Critical System Interactions
|
||||
|
||||
**Conflict Resolution Flow:**
|
||||
1. Merkle trees detect divergent data between nodes (`cluster/merkle.go`)
|
||||
2. Sync service fetches conflicting keys (`cluster/sync.go:fetchAndCompareData`)
|
||||
3. Sophisticated conflict resolution logic in `resolveConflict()`:
|
||||
- Same timestamp → Apply "oldest-node rule" (earliest `joined_timestamp` wins)
|
||||
- Tie-breaker → UUID comparison for deterministic results
|
||||
- Winner's data automatically replicated to losing nodes
|
||||
|
||||
**Authentication & Authorization:**
|
||||
- JWT tokens with scoped permissions (`auth/jwt.go`)
|
||||
- POSIX-inspired 12-bit permission system (`types/types.go:52-75`)
|
||||
- Resource ownership metadata with TTL support (`types/ResourceMetadata`)
|
||||
|
||||
**Storage Strategy:**
|
||||
- **Main keys**: Direct path mapping (`users/john/profile`)
|
||||
- **Index keys**: `_ts:{timestamp}:{path}` for time-based queries
|
||||
- **Compression**: Optional ZSTD compression (`storage/compression.go`)
|
||||
- **Revisions**: Optional revision history (`storage/revision.go`)
|
||||
|
||||
### Configuration Architecture
|
||||
|
||||
The system uses feature toggles extensively (`types/Config:271-280`):
|
||||
```yaml
|
||||
auth_enabled: true # JWT authentication system
|
||||
tamper_logging_enabled: true # Cryptographic audit trail
|
||||
clustering_enabled: true # Gossip protocol and sync
|
||||
rate_limiting_enabled: true # Per-client rate limiting
|
||||
revision_history_enabled: true # Automatic versioning
|
||||
|
||||
# Anonymous access control (Issue #5 - when auth_enabled: true)
|
||||
allow_anonymous_read: false # Allow unauthenticated read access to KV endpoints
|
||||
allow_anonymous_write: false # Allow unauthenticated write access to KV endpoints
|
||||
```
|
||||
|
||||
**Security Note**: DELETE operations always require authentication when `auth_enabled: true`, regardless of anonymous access settings.
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
#### Integration Test Suite (`integration_test.sh`)
|
||||
- **Build verification** - Ensures binary compiles correctly
|
||||
- **Basic functionality** - Single-node CRUD operations
|
||||
- **Cluster formation** - 2-node gossip protocol and data replication
|
||||
- **Conflict resolution** - Automated conflict detection and resolution using `test_conflict.go`
|
||||
- **Authentication middleware** - Comprehensive security testing (Issue #4):
|
||||
- Admin endpoints properly reject unauthenticated requests
|
||||
- Admin endpoints work with valid JWT tokens
|
||||
- KV endpoints respect anonymous access configuration
|
||||
- Automatic root account creation and token extraction
|
||||
|
||||
The test suite uses sophisticated retry logic and timing to handle the eventually consistent nature of the system.
|
||||
|
||||
#### Conflict Testing Utility (`test_conflict.go`)
|
||||
Creates two BadgerDB instances with intentionally conflicting data (same path, same timestamp, different UUIDs) to test the conflict resolution algorithm.
|
||||
|
||||
### Development Notes
|
||||
|
||||
#### Key Constraints
|
||||
- **Eventually Consistent**: All operations succeed locally first, then replicate
|
||||
- **Local-First Truth**: Nodes operate independently and sync in background
|
||||
- **No Transactions**: Each key operation is atomic and independent
|
||||
- **Hierarchical Keys**: Support for path-like structures (`/home/room/closet/socks`)
|
||||
|
||||
#### Critical Timing Considerations
|
||||
- **Gossip intervals**: 1-2 minutes for membership updates
|
||||
- **Sync intervals**: 5 minutes for regular data sync, 2 minutes for catch-up
|
||||
- **Conflict resolution**: Typically resolves within 10-30 seconds after detection
|
||||
- **Bootstrap sync**: Up to 30 days of historical data for new nodes
|
||||
|
||||
#### Main Entry Point Flow
|
||||
1. `main.go` parses command-line arguments for subcommands (`start`, `stop`, `status`, `restart`)
|
||||
2. For daemon mode: `daemon.Daemonize()` spawns background process and manages PID files
|
||||
3. For server mode: loads config (auto-generates default if missing)
|
||||
4. `server.NewServer()` initializes all subsystems
|
||||
5. Graceful shutdown handling with `SIGINT`/`SIGTERM`
|
||||
6. All business logic delegated to modular packages
|
||||
|
||||
#### Daemon Architecture
|
||||
- **PID Management**: Global PID files stored in `~/.kvs/pids/` for cross-directory access
|
||||
- **Logging**: Daemon logs written to `~/.kvs/logs/{config-name}.log`
|
||||
- **Process Lifecycle**: Spawns detached process via `exec.Command()` with `Setsid: true`
|
||||
- **Config Normalization**: Supports both `node1` and `node1.yaml` formats
|
||||
- **Stale PID Detection**: Checks process existence via `Signal(0)` before operations
|
||||
|
||||
This architecture enables easy feature addition, comprehensive testing, and reliable operation in distributed environments while maintaining simplicity for single-node deployments.
|
469
README.md
469
README.md
@@ -6,12 +6,14 @@ A minimalistic, clustered key-value database system written in Go that prioritiz
|
||||
|
||||
- **Hierarchical Keys**: Support for structured paths (e.g., `/home/room/closet/socks`)
|
||||
- **Eventual Consistency**: Local operations are fast, replication happens in background
|
||||
- **Gossip Protocol**: Decentralized node discovery and failure detection
|
||||
- **Sophisticated Conflict Resolution**: Majority vote with oldest-node tie-breaking
|
||||
- **Merkle Tree Sync**: Efficient data synchronization with cryptographic integrity
|
||||
- **Sophisticated Conflict Resolution**: Oldest-node rule with UUID tie-breaking
|
||||
- **JWT Authentication**: Full authentication system with POSIX-inspired permissions
|
||||
- **Local-First Truth**: All operations work locally first, sync globally later
|
||||
- **Read-Only Mode**: Configurable mode for reducing write load
|
||||
- **Gradual Bootstrapping**: New nodes integrate smoothly without overwhelming cluster
|
||||
- **Zero Dependencies**: Single binary with embedded BadgerDB storage
|
||||
- **Modular Architecture**: Clean separation of concerns with feature toggles
|
||||
- **Comprehensive Features**: TTL support, rate limiting, tamper logging, automated backups
|
||||
- **Zero External Dependencies**: Single binary with embedded BadgerDB storage
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
@@ -21,24 +23,36 @@ A minimalistic, clustered key-value database system written in Go that prioritiz
|
||||
│ (Go Service) │ │ (Go Service) │ │ (Go Service) │
|
||||
│ │ │ │ │ │
|
||||
│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │
|
||||
│ │ HTTP Server │ │◄──►│ │ HTTP Server │ │◄──►│ │ HTTP Server │ │
|
||||
│ │ (API) │ │ │ │ (API) │ │ │ │ (API) │ │
|
||||
│ │HTTP API+Auth│ │◄──►│ │HTTP API+Auth│ │◄──►│ │HTTP API+Auth│ │
|
||||
│ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │
|
||||
│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │
|
||||
│ │ Gossip │ │◄──►│ │ Gossip │ │◄──►│ │ Gossip │ │
|
||||
│ │ Protocol │ │ │ │ Protocol │ │ │ │ Protocol │ │
|
||||
│ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │
|
||||
│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │
|
||||
│ │ BadgerDB │ │ │ │ BadgerDB │ │ │ │ BadgerDB │ │
|
||||
│ │ (Local KV) │ │ │ │ (Local KV) │ │ │ │ (Local KV) │ │
|
||||
│ │Merkle Sync │ │◄──►│ │Merkle Sync │ │◄──►│ │Merkle Sync │ │
|
||||
│ │& Conflict │ │ │ │& Conflict │ │ │ │& Conflict │ │
|
||||
│ │ Resolution │ │ │ │ Resolution │ │ │ │ Resolution │ │
|
||||
│ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │
|
||||
│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │
|
||||
│ │Storage+ │ │ │ │Storage+ │ │ │ │Storage+ │ │
|
||||
│ │Features │ │ │ │Features │ │ │ │Features │ │
|
||||
│ │(BadgerDB) │ │ │ │(BadgerDB) │ │ │ │(BadgerDB) │ │
|
||||
│ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
▲
|
||||
│
|
||||
External Clients
|
||||
External Clients (JWT Auth)
|
||||
```
|
||||
|
||||
Each node is fully autonomous and communicates with peers via HTTP REST API for both external client requests and internal cluster operations.
|
||||
### Modular Design
|
||||
KVS features a clean modular architecture with dedicated packages:
|
||||
- **`auth/`** - JWT authentication and POSIX-inspired permissions
|
||||
- **`cluster/`** - Gossip protocol, Merkle tree sync, and conflict resolution
|
||||
- **`storage/`** - BadgerDB abstraction with compression and revisions
|
||||
- **`server/`** - HTTP API, routing, and lifecycle management
|
||||
- **`features/`** - TTL, rate limiting, tamper logging, backup utilities
|
||||
- **`config/`** - Configuration management with auto-generation
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
@@ -55,11 +69,67 @@ go build -o kvs .
|
||||
|
||||
### Quick Test
|
||||
```bash
|
||||
# Start standalone node
|
||||
./kvs
|
||||
# Start standalone node (uses config.yaml if it exists, or creates it)
|
||||
./kvs start config.yaml
|
||||
|
||||
# Test the API
|
||||
curl http://localhost:8080/health
|
||||
|
||||
# Check status
|
||||
./kvs status
|
||||
|
||||
# Stop when done
|
||||
./kvs stop config
|
||||
```
|
||||
|
||||
## 🎮 Process Management
|
||||
|
||||
KVS includes systemd-style daemon commands for easy process management:
|
||||
|
||||
```bash
|
||||
# Start as background daemon
|
||||
./kvs start config.yaml # or just: ./kvs start config
|
||||
./kvs start node1.yaml # Start with custom config
|
||||
|
||||
# Check status
|
||||
./kvs status # Show all running instances
|
||||
./kvs status node1 # Show specific instance
|
||||
|
||||
# Stop daemon
|
||||
./kvs stop node1 # Graceful shutdown
|
||||
|
||||
# Restart daemon
|
||||
./kvs restart node1 # Stop and start
|
||||
|
||||
# Run in foreground (traditional)
|
||||
./kvs node1.yaml # Logs to stdout
|
||||
```
|
||||
|
||||
### Daemon Features
|
||||
- **Global PID tracking**: PID files stored in `~/.kvs/pids/` (works from any directory)
|
||||
- **Automatic logging**: Logs written to `~/.kvs/logs/{config-name}.log`
|
||||
- **Flexible naming**: Config extension optional (`node1` or `node1.yaml` both work)
|
||||
- **Graceful shutdown**: SIGTERM sent for clean shutdown
|
||||
- **Stale PID cleanup**: Automatically detects and cleans dead processes
|
||||
- **Multi-instance**: Run multiple KVS instances on same machine
|
||||
|
||||
### Example Workflow
|
||||
```bash
|
||||
# Start 3-node cluster as daemons
|
||||
./kvs start node1.yaml
|
||||
./kvs start node2.yaml
|
||||
./kvs start node3.yaml
|
||||
|
||||
# Check cluster status
|
||||
./kvs status
|
||||
|
||||
# View logs
|
||||
tail -f ~/.kvs/logs/kvs_node1.yaml.log
|
||||
|
||||
# Stop entire cluster
|
||||
./kvs stop node1
|
||||
./kvs stop node2
|
||||
./kvs stop node3
|
||||
```
|
||||
|
||||
## ⚙️ Configuration
|
||||
@@ -67,20 +137,47 @@ curl http://localhost:8080/health
|
||||
KVS uses YAML configuration files. On first run, a default `config.yaml` is automatically generated:
|
||||
|
||||
```yaml
|
||||
node_id: "hostname" # Unique node identifier
|
||||
bind_address: "127.0.0.1" # IP address to bind to
|
||||
port: 8080 # HTTP port
|
||||
data_dir: "./data" # Directory for BadgerDB storage
|
||||
seed_nodes: [] # List of seed nodes for cluster joining
|
||||
read_only: false # Enable read-only mode
|
||||
log_level: "info" # Logging level (debug, info, warn, error)
|
||||
gossip_interval_min: 60 # Min gossip interval (seconds)
|
||||
gossip_interval_max: 120 # Max gossip interval (seconds)
|
||||
sync_interval: 300 # Regular sync interval (seconds)
|
||||
catchup_interval: 120 # Catch-up sync interval (seconds)
|
||||
bootstrap_max_age_hours: 720 # Max age for bootstrap sync (hours)
|
||||
throttle_delay_ms: 100 # Delay between sync requests (ms)
|
||||
fetch_delay_ms: 50 # Delay between data fetches (ms)
|
||||
node_id: "hostname" # Unique node identifier
|
||||
bind_address: "127.0.0.1" # IP address to bind to
|
||||
port: 8080 # HTTP port
|
||||
data_dir: "./data" # Directory for BadgerDB storage
|
||||
seed_nodes: [] # List of seed nodes for cluster joining
|
||||
read_only: false # Enable read-only mode
|
||||
log_level: "info" # Logging level (debug, info, warn, error)
|
||||
|
||||
# Cluster timing configuration
|
||||
gossip_interval_min: 60 # Min gossip interval (seconds)
|
||||
gossip_interval_max: 120 # Max gossip interval (seconds)
|
||||
sync_interval: 300 # Regular sync interval (seconds)
|
||||
catchup_interval: 120 # Catch-up sync interval (seconds)
|
||||
bootstrap_max_age_hours: 720 # Max age for bootstrap sync (hours)
|
||||
throttle_delay_ms: 100 # Delay between sync requests (ms)
|
||||
fetch_delay_ms: 50 # Delay between data fetches (ms)
|
||||
|
||||
# Feature configuration
|
||||
compression_enabled: true # Enable ZSTD compression
|
||||
compression_level: 3 # Compression level (1-19)
|
||||
default_ttl: "0" # Default TTL ("0" = no expiry)
|
||||
max_json_size: 1048576 # Max JSON payload size (1MB)
|
||||
rate_limit_requests: 100 # Requests per window
|
||||
rate_limit_window: "1m" # Rate limit window
|
||||
|
||||
# Feature toggles
|
||||
auth_enabled: true # JWT authentication system
|
||||
tamper_logging_enabled: true # Cryptographic audit trail
|
||||
clustering_enabled: true # Gossip protocol and sync
|
||||
rate_limiting_enabled: true # Rate limiting
|
||||
revision_history_enabled: true # Automatic versioning
|
||||
|
||||
# Anonymous access control (when auth_enabled: true)
|
||||
allow_anonymous_read: false # Allow unauthenticated read access to KV endpoints
|
||||
allow_anonymous_write: false # Allow unauthenticated write access to KV endpoints
|
||||
|
||||
# Backup configuration
|
||||
backup_enabled: true # Automated backups
|
||||
backup_schedule: "0 0 * * *" # Daily at midnight (cron format)
|
||||
backup_path: "./backups" # Backup directory
|
||||
backup_retention: 7 # Days to keep backups
|
||||
```
|
||||
|
||||
### Custom Configuration
|
||||
@@ -97,11 +194,20 @@ fetch_delay_ms: 50 # Delay between data fetches (ms)
|
||||
```bash
|
||||
PUT /kv/{path}
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <jwt-token> # Required if auth_enabled && !allow_anonymous_write
|
||||
|
||||
# Basic storage
|
||||
curl -X PUT http://localhost:8080/kv/users/john/profile \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer eyJ..." \
|
||||
-d '{"name":"John Doe","age":30,"email":"john@example.com"}'
|
||||
|
||||
# Storage with TTL
|
||||
curl -X PUT http://localhost:8080/kv/cache/session/abc123 \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer eyJ..." \
|
||||
-d '{"data":{"user_id":"john"}, "ttl":"1h"}'
|
||||
|
||||
# Response
|
||||
{
|
||||
"uuid": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
|
||||
@@ -112,25 +218,62 @@ curl -X PUT http://localhost:8080/kv/users/john/profile \
|
||||
#### Retrieve Data
|
||||
```bash
|
||||
GET /kv/{path}
|
||||
Authorization: Bearer <jwt-token> # Required if auth_enabled && !allow_anonymous_read
|
||||
|
||||
curl http://localhost:8080/kv/users/john/profile
|
||||
curl -H "Authorization: Bearer eyJ..." http://localhost:8080/kv/users/john/profile
|
||||
|
||||
# Response
|
||||
# Response (full StoredValue format)
|
||||
{
|
||||
"name": "John Doe",
|
||||
"age": 30,
|
||||
"email": "john@example.com"
|
||||
"uuid": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
|
||||
"timestamp": 1672531200000,
|
||||
"data": {
|
||||
"name": "John Doe",
|
||||
"age": 30,
|
||||
"email": "john@example.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Delete Data
|
||||
```bash
|
||||
DELETE /kv/{path}
|
||||
Authorization: Bearer <jwt-token> # Always required when auth_enabled (no anonymous delete)
|
||||
|
||||
curl -X DELETE http://localhost:8080/kv/users/john/profile
|
||||
curl -X DELETE -H "Authorization: Bearer eyJ..." http://localhost:8080/kv/users/john/profile
|
||||
# Returns: 204 No Content
|
||||
```
|
||||
|
||||
### Authentication Operations (`/auth/`)
|
||||
|
||||
#### Create User
|
||||
```bash
|
||||
POST /auth/users
|
||||
Content-Type: application/json
|
||||
|
||||
curl -X POST http://localhost:8080/auth/users \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"nickname":"john"}'
|
||||
|
||||
# Response
|
||||
{"uuid": "user-abc123"}
|
||||
```
|
||||
|
||||
#### Create API Token
|
||||
```bash
|
||||
POST /auth/tokens
|
||||
Content-Type: application/json
|
||||
|
||||
curl -X POST http://localhost:8080/auth/tokens \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"user_uuid":"user-abc123", "scopes":["read","write"]}'
|
||||
|
||||
# Response
|
||||
{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"expires_at": 1672617600000
|
||||
}
|
||||
```
|
||||
|
||||
### Cluster Operations (`/members/`)
|
||||
|
||||
#### View Cluster Members
|
||||
@@ -149,12 +292,6 @@ curl http://localhost:8080/members/
|
||||
]
|
||||
```
|
||||
|
||||
#### Join Cluster (Internal)
|
||||
```bash
|
||||
POST /members/join
|
||||
# Used internally during bootstrap process
|
||||
```
|
||||
|
||||
#### Health Check
|
||||
```bash
|
||||
GET /health
|
||||
@@ -169,6 +306,20 @@ curl http://localhost:8080/health
|
||||
}
|
||||
```
|
||||
|
||||
### Merkle Tree Operations (`/sync/`)
|
||||
|
||||
#### Get Merkle Root
|
||||
```bash
|
||||
GET /sync/merkle/root
|
||||
# Used internally for data synchronization
|
||||
```
|
||||
|
||||
#### Range Queries
|
||||
```bash
|
||||
GET /kv/_range?start_key=users/&end_key=users/z&limit=100
|
||||
# Fetch key ranges for synchronization
|
||||
```
|
||||
|
||||
## 🏘️ Cluster Setup
|
||||
|
||||
### Single Node (Standalone)
|
||||
@@ -187,6 +338,8 @@ seed_nodes: [] # Empty = standalone mode
|
||||
node_id: "node1"
|
||||
port: 8081
|
||||
seed_nodes: [] # First node, no seeds needed
|
||||
auth_enabled: true
|
||||
clustering_enabled: true
|
||||
```
|
||||
|
||||
#### Node 2 (Joins via Node 1)
|
||||
@@ -195,6 +348,8 @@ seed_nodes: [] # First node, no seeds needed
|
||||
node_id: "node2"
|
||||
port: 8082
|
||||
seed_nodes: ["127.0.0.1:8081"] # Points to node1
|
||||
auth_enabled: true
|
||||
clustering_enabled: true
|
||||
```
|
||||
|
||||
#### Node 3 (Joins via Node 1 & 2)
|
||||
@@ -203,18 +358,29 @@ seed_nodes: ["127.0.0.1:8081"] # Points to node1
|
||||
node_id: "node3"
|
||||
port: 8083
|
||||
seed_nodes: ["127.0.0.1:8081", "127.0.0.1:8082"] # Multiple seeds for reliability
|
||||
auth_enabled: true
|
||||
clustering_enabled: true
|
||||
```
|
||||
|
||||
#### Start the Cluster
|
||||
```bash
|
||||
# Terminal 1
|
||||
./kvs node1.yaml
|
||||
# Start as daemons
|
||||
./kvs start node1.yaml
|
||||
sleep 2
|
||||
./kvs start node2.yaml
|
||||
sleep 2
|
||||
./kvs start node3.yaml
|
||||
|
||||
# Terminal 2 (wait a few seconds)
|
||||
./kvs node2.yaml
|
||||
# Verify cluster formation
|
||||
curl http://localhost:8081/members/ # Should show all 3 nodes
|
||||
|
||||
# Terminal 3 (wait a few seconds)
|
||||
./kvs node3.yaml
|
||||
# Check daemon status
|
||||
./kvs status
|
||||
|
||||
# Stop cluster when done
|
||||
./kvs stop node1
|
||||
./kvs stop node2
|
||||
./kvs stop node3
|
||||
```
|
||||
|
||||
## 🔄 How It Works
|
||||
@@ -224,20 +390,30 @@ seed_nodes: ["127.0.0.1:8081", "127.0.0.1:8082"] # Multiple seeds for reliabili
|
||||
- Failed nodes are detected via timeout (5 minutes) and removed (10 minutes)
|
||||
- New members are automatically discovered and added to local member lists
|
||||
|
||||
### Data Synchronization
|
||||
- **Regular Sync**: Every 5 minutes, nodes compare their latest 15 data items with a random peer
|
||||
### Merkle Tree Synchronization
|
||||
- **Merkle Trees**: Each node builds cryptographic trees of their data for efficient comparison
|
||||
- **Regular Sync**: Every 5 minutes, nodes compare Merkle roots and sync divergent branches
|
||||
- **Catch-up Sync**: Every 2 minutes when nodes detect they're significantly behind
|
||||
- **Bootstrap Sync**: New nodes gradually fetch historical data up to 30 days old
|
||||
- **Efficient Detection**: Only synchronizes actual differences, not entire datasets
|
||||
|
||||
### Conflict Resolution
|
||||
### Sophisticated Conflict Resolution
|
||||
When two nodes have different data for the same key with identical timestamps:
|
||||
|
||||
1. **Majority Vote**: Query all healthy cluster members for their version
|
||||
2. **Tie-Breaker**: If votes are tied, the version from the oldest node (earliest `joined_timestamp`) wins
|
||||
3. **Automatic Resolution**: Losing nodes automatically fetch and store the winning version
|
||||
1. **Detection**: Merkle tree comparison identifies conflicting keys
|
||||
2. **Oldest-Node Rule**: The version from the node with earliest `joined_timestamp` wins
|
||||
3. **UUID Tie-Breaker**: If join times are identical, lexicographically smaller UUID wins
|
||||
4. **Automatic Resolution**: Losing nodes automatically fetch and store the winning version
|
||||
5. **Consistency**: All nodes converge to the same data within seconds
|
||||
|
||||
### Authentication & Authorization
|
||||
- **JWT Tokens**: Secure API access with scoped permissions
|
||||
- **POSIX-Inspired ACLs**: 12-bit permission system (owner/group/others with create/delete/write/read)
|
||||
- **Resource Metadata**: Each stored item has ownership and permission information
|
||||
- **Feature Toggle**: Can be completely disabled for simpler deployments
|
||||
|
||||
### Operational Modes
|
||||
- **Normal**: Full read/write capabilities
|
||||
- **Normal**: Full read/write capabilities with all features
|
||||
- **Read-Only**: Rejects external writes but accepts internal replication
|
||||
- **Syncing**: Temporary mode during bootstrap, rejects external writes
|
||||
|
||||
@@ -245,57 +421,158 @@ When two nodes have different data for the same key with identical timestamps:
|
||||
|
||||
### Running Tests
|
||||
```bash
|
||||
# Basic functionality test
|
||||
# Build and run comprehensive integration tests
|
||||
go build -o kvs .
|
||||
./kvs &
|
||||
./integration_test.sh
|
||||
|
||||
# Manual basic functionality test
|
||||
./kvs start config.yaml
|
||||
sleep 2
|
||||
curl http://localhost:8080/health
|
||||
pkill kvs
|
||||
./kvs stop config
|
||||
|
||||
# Cluster test with provided configs
|
||||
./kvs node1.yaml &
|
||||
./kvs node2.yaml &
|
||||
./kvs node3.yaml &
|
||||
# Manual cluster test (requires creating configs)
|
||||
echo 'node_id: "test1"
|
||||
port: 8081
|
||||
seed_nodes: []
|
||||
auth_enabled: false' > test1.yaml
|
||||
|
||||
# Test data replication
|
||||
echo 'node_id: "test2"
|
||||
port: 8082
|
||||
seed_nodes: ["127.0.0.1:8081"]
|
||||
auth_enabled: false' > test2.yaml
|
||||
|
||||
./kvs start test1.yaml
|
||||
sleep 2
|
||||
./kvs start test2.yaml
|
||||
|
||||
# Test data replication (wait for cluster formation)
|
||||
sleep 10
|
||||
curl -X PUT http://localhost:8081/kv/test/data \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"message":"hello world"}'
|
||||
|
||||
# Wait 30+ seconds for sync, then check other nodes
|
||||
# Wait for Merkle sync, then check replication
|
||||
sleep 30
|
||||
curl http://localhost:8082/kv/test/data
|
||||
curl http://localhost:8083/kv/test/data
|
||||
|
||||
# Cleanup
|
||||
pkill kvs
|
||||
./kvs stop test1
|
||||
./kvs stop test2
|
||||
rm test1.yaml test2.yaml
|
||||
```
|
||||
|
||||
### Conflict Resolution Testing
|
||||
```bash
|
||||
# Create conflicting data scenario
|
||||
rm -rf data1 data2
|
||||
mkdir data1 data2
|
||||
go run test_conflict.go data1 data2
|
||||
# Create conflicting data scenario using utility
|
||||
go run test_conflict.go /tmp/conflict1 /tmp/conflict2
|
||||
|
||||
# Create configs for conflict test
|
||||
echo 'node_id: "conflict1"
|
||||
port: 9111
|
||||
data_dir: "/tmp/conflict1"
|
||||
seed_nodes: []
|
||||
auth_enabled: false
|
||||
log_level: "debug"' > conflict1.yaml
|
||||
|
||||
echo 'node_id: "conflict2"
|
||||
port: 9112
|
||||
data_dir: "/tmp/conflict2"
|
||||
seed_nodes: ["127.0.0.1:9111"]
|
||||
auth_enabled: false
|
||||
log_level: "debug"' > conflict2.yaml
|
||||
|
||||
# Start nodes with conflicting data
|
||||
./kvs node1.yaml &
|
||||
./kvs node2.yaml &
|
||||
./kvs start conflict1.yaml
|
||||
sleep 2
|
||||
./kvs start conflict2.yaml
|
||||
|
||||
# Watch logs for conflict resolution
|
||||
# Both nodes will converge to same data within ~30 seconds
|
||||
tail -f ~/.kvs/logs/kvs_conflict1.yaml.log ~/.kvs/logs/kvs_conflict2.yaml.log &
|
||||
|
||||
# Both nodes will converge within ~10-30 seconds
|
||||
# Check final state
|
||||
sleep 30
|
||||
curl http://localhost:9111/kv/test/conflict/data
|
||||
curl http://localhost:9112/kv/test/conflict/data
|
||||
|
||||
# Cleanup
|
||||
./kvs stop conflict1
|
||||
./kvs stop conflict2
|
||||
rm conflict1.yaml conflict2.yaml
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
```bash
|
||||
# Format and lint
|
||||
go fmt ./...
|
||||
go vet ./...
|
||||
|
||||
# Dependency management
|
||||
go mod tidy
|
||||
go mod verify
|
||||
|
||||
# Build verification
|
||||
go build .
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
```
|
||||
kvs/
|
||||
├── main.go # Main application with all functionality
|
||||
├── config.yaml # Default configuration (auto-generated)
|
||||
├── test_conflict.go # Conflict resolution testing utility
|
||||
├── node1.yaml # Example cluster node config
|
||||
├── node2.yaml # Example cluster node config
|
||||
├── node3.yaml # Example cluster node config
|
||||
├── go.mod # Go module dependencies
|
||||
├── go.sum # Go module checksums
|
||||
└── README.md # This documentation
|
||||
├── main.go # Main application entry point
|
||||
├── config.yaml # Default configuration (auto-generated)
|
||||
├── integration_test.sh # Comprehensive test suite
|
||||
├── test_conflict.go # Conflict resolution testing utility
|
||||
├── CLAUDE.md # Development guidance for Claude Code
|
||||
├── go.mod # Go module dependencies
|
||||
├── go.sum # Go module checksums
|
||||
├── README.md # This documentation
|
||||
│
|
||||
├── auth/ # Authentication & authorization
|
||||
│ ├── auth.go # Main auth logic
|
||||
│ ├── jwt.go # JWT token management
|
||||
│ ├── middleware.go # HTTP middleware
|
||||
│ ├── permissions.go # POSIX-inspired ACL system
|
||||
│ └── storage.go # Auth data storage
|
||||
│
|
||||
├── cluster/ # Distributed systems components
|
||||
│ ├── bootstrap.go # New node integration
|
||||
│ ├── gossip.go # Membership protocol
|
||||
│ ├── merkle.go # Merkle tree implementation
|
||||
│ └── sync.go # Data synchronization & conflict resolution
|
||||
│
|
||||
├── config/ # Configuration management
|
||||
│ └── config.go # Config loading & defaults
|
||||
│
|
||||
├── daemon/ # Process management
|
||||
│ ├── daemonize.go # Background process spawning
|
||||
│ └── pid.go # PID file management
|
||||
│
|
||||
├── features/ # Utility features
|
||||
│ ├── auth.go # Auth utilities
|
||||
│ ├── backup.go # Backup system
|
||||
│ ├── features.go # Feature toggles
|
||||
│ ├── ratelimit.go # Rate limiting
|
||||
│ ├── revision.go # Revision history
|
||||
│ ├── tamperlog.go # Tamper-evident logging
|
||||
│ └── validation.go # TTL parsing
|
||||
│
|
||||
├── server/ # HTTP server & API
|
||||
│ ├── handlers.go # Request handlers
|
||||
│ ├── lifecycle.go # Server lifecycle
|
||||
│ ├── routes.go # Route definitions
|
||||
│ └── server.go # Server setup
|
||||
│
|
||||
├── storage/ # Data storage abstraction
|
||||
│ ├── compression.go # ZSTD compression
|
||||
│ ├── revision.go # Revision history
|
||||
│ └── storage.go # BadgerDB interface
|
||||
│
|
||||
├── types/ # Shared type definitions
|
||||
│ └── types.go # All data structures
|
||||
│
|
||||
└── utils/ # Utilities
|
||||
└── hash.go # Cryptographic hashing
|
||||
```
|
||||
|
||||
### Key Data Structures
|
||||
@@ -318,6 +595,7 @@ type StoredValue struct {
|
||||
|
||||
| Setting | Description | Default | Notes |
|
||||
|---------|-------------|---------|-------|
|
||||
| **Core Settings** |
|
||||
| `node_id` | Unique identifier for this node | hostname | Must be unique across cluster |
|
||||
| `bind_address` | IP address to bind HTTP server | "127.0.0.1" | Use 0.0.0.0 for external access |
|
||||
| `port` | HTTP port for API and cluster communication | 8080 | Must be accessible to peers |
|
||||
@@ -325,8 +603,20 @@ type StoredValue struct {
|
||||
| `seed_nodes` | List of initial cluster nodes | [] | Empty = standalone mode |
|
||||
| `read_only` | Enable read-only mode | false | Accepts replication, rejects client writes |
|
||||
| `log_level` | Logging verbosity | "info" | debug/info/warn/error |
|
||||
| **Cluster Timing** |
|
||||
| `gossip_interval_min/max` | Gossip frequency range | 60-120 sec | Randomized interval |
|
||||
| `sync_interval` | Regular sync frequency | 300 sec | How often to sync with peers |
|
||||
| `sync_interval` | Regular Merkle sync frequency | 300 sec | How often to sync with peers |
|
||||
| `catchup_interval` | Catch-up sync frequency | 120 sec | Faster sync when behind |
|
||||
| `bootstrap_max_age_hours` | Max historical data to sync | 720 hours | 30 days default |
|
||||
| **Feature Toggles** |
|
||||
| `auth_enabled` | JWT authentication system | true | Complete auth/authz system |
|
||||
| `allow_anonymous_read` | Allow unauthenticated read access | false | When auth_enabled, controls KV GET endpoints |
|
||||
| `allow_anonymous_write` | Allow unauthenticated write access | false | When auth_enabled, controls KV PUT endpoints |
|
||||
| `clustering_enabled` | Gossip protocol and sync | true | Distributed mode |
|
||||
| `compression_enabled` | ZSTD compression | true | Reduces storage size |
|
||||
| `rate_limiting_enabled` | Rate limiting | true | Per-client limits |
|
||||
| `tamper_logging_enabled` | Cryptographic audit trail | true | Security logging |
|
||||
| `revision_history_enabled` | Automatic versioning | true | Data history tracking |
|
||||
| `catchup_interval` | Catch-up sync frequency | 120 sec | Faster sync when behind |
|
||||
| `bootstrap_max_age_hours` | Max historical data to sync | 720 hours | 30 days default |
|
||||
| `throttle_delay_ms` | Delay between sync requests | 100 ms | Prevents overwhelming peers |
|
||||
@@ -346,24 +636,27 @@ type StoredValue struct {
|
||||
- IPv4 private networks supported (IPv6 not tested)
|
||||
|
||||
### Limitations
|
||||
- No authentication/authorization (planned for future releases)
|
||||
- No encryption in transit (use reverse proxy for TLS)
|
||||
- No cross-key transactions
|
||||
- No cross-key transactions or ACID guarantees
|
||||
- No complex queries (key-based lookups only)
|
||||
- No data compression (planned for future releases)
|
||||
- No automatic data sharding (single keyspace per cluster)
|
||||
- No multi-datacenter replication
|
||||
|
||||
### Performance Characteristics
|
||||
- **Read Latency**: ~1ms (local BadgerDB lookup)
|
||||
- **Write Latency**: ~5ms (local write + timestamp indexing)
|
||||
- **Replication Lag**: 30 seconds - 5 minutes depending on sync cycles
|
||||
- **Memory Usage**: Minimal (BadgerDB handles caching efficiently)
|
||||
- **Disk Usage**: Raw JSON + metadata overhead (~20-30%)
|
||||
- **Write Latency**: ~5ms (local write + indexing + optional compression)
|
||||
- **Replication Lag**: 10-30 seconds with Merkle tree sync
|
||||
- **Memory Usage**: Minimal (BadgerDB + Merkle tree caching)
|
||||
- **Disk Usage**: Raw JSON + metadata + optional compression (10-50% savings)
|
||||
- **Conflict Resolution**: Sub-second convergence time
|
||||
- **Cluster Formation**: ~10-20 seconds for gossip stabilization
|
||||
|
||||
## 🛡️ Production Considerations
|
||||
|
||||
### Deployment
|
||||
- Use systemd or similar for process management
|
||||
- Configure log rotation for JSON logs
|
||||
- Built-in daemon commands (`start`/`stop`/`restart`/`status`) for process management
|
||||
- Alternatively, use systemd or similar for advanced orchestration
|
||||
- Logs automatically written to `~/.kvs/logs/` (configure log rotation)
|
||||
- Set up monitoring for `/health` endpoint
|
||||
- Use reverse proxy (nginx/traefik) for TLS and load balancing
|
||||
|
||||
|
71
auth/auth.go
71
auth/auth.go
@@ -26,20 +26,22 @@ type AuthContext struct {
|
||||
type AuthService struct {
|
||||
db *badger.DB
|
||||
logger *logrus.Logger
|
||||
config *types.Config
|
||||
}
|
||||
|
||||
// NewAuthService creates a new authentication service
|
||||
func NewAuthService(db *badger.DB, logger *logrus.Logger) *AuthService {
|
||||
func NewAuthService(db *badger.DB, logger *logrus.Logger, config *types.Config) *AuthService {
|
||||
return &AuthService{
|
||||
db: db,
|
||||
logger: logger,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// StoreAPIToken stores an API token in BadgerDB with TTL
|
||||
func (s *AuthService) StoreAPIToken(tokenString string, userUUID string, scopes []string, expiresAt int64) error {
|
||||
tokenHash := utils.HashToken(tokenString)
|
||||
|
||||
|
||||
apiToken := types.APIToken{
|
||||
TokenHash: tokenHash,
|
||||
UserUUID: userUUID,
|
||||
@@ -55,13 +57,13 @@ func (s *AuthService) StoreAPIToken(tokenString string, userUUID string, scopes
|
||||
|
||||
return s.db.Update(func(txn *badger.Txn) error {
|
||||
entry := badger.NewEntry([]byte(TokenStorageKey(tokenHash)), tokenData)
|
||||
|
||||
|
||||
// Set TTL to the token expiration time
|
||||
ttl := time.Until(time.Unix(expiresAt, 0))
|
||||
if ttl > 0 {
|
||||
entry = entry.WithTTL(ttl)
|
||||
}
|
||||
|
||||
|
||||
return txn.SetEntry(entry)
|
||||
})
|
||||
}
|
||||
@@ -69,7 +71,7 @@ func (s *AuthService) StoreAPIToken(tokenString string, userUUID string, scopes
|
||||
// GetAPIToken retrieves an API token from BadgerDB by hash
|
||||
func (s *AuthService) GetAPIToken(tokenHash string) (*types.APIToken, error) {
|
||||
var apiToken types.APIToken
|
||||
|
||||
|
||||
err := s.db.View(func(txn *badger.Txn) error {
|
||||
item, err := txn.Get([]byte(TokenStorageKey(tokenHash)))
|
||||
if err != nil {
|
||||
@@ -196,10 +198,67 @@ func (s *AuthService) CheckResourcePermission(authCtx *AuthContext, resourceKey
|
||||
return CheckPermission(metadata.Permissions, operation, isOwner, isGroupMember)
|
||||
}
|
||||
|
||||
// GetResourceMetadata retrieves metadata for a resource
|
||||
func (s *AuthService) GetResourceMetadata(resourceKey string) (*types.ResourceMetadata, error) {
|
||||
var metadata types.ResourceMetadata
|
||||
|
||||
err := s.db.View(func(txn *badger.Txn) error {
|
||||
item, err := txn.Get([]byte(ResourceMetadataKey(resourceKey)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return item.Value(func(val []byte) error {
|
||||
return json.Unmarshal(val, &metadata)
|
||||
})
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &metadata, nil
|
||||
}
|
||||
|
||||
// SetResourceMetadata stores metadata for a resource
|
||||
func (s *AuthService) SetResourceMetadata(resourceKey string, metadata *types.ResourceMetadata) error {
|
||||
metadataBytes, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal metadata: %v", err)
|
||||
}
|
||||
|
||||
return s.db.Update(func(txn *badger.Txn) error {
|
||||
return txn.Set([]byte(ResourceMetadataKey(resourceKey)), metadataBytes)
|
||||
})
|
||||
}
|
||||
|
||||
// GetAuthContext retrieves auth context from request context
|
||||
func GetAuthContext(ctx context.Context) *AuthContext {
|
||||
if authCtx, ok := ctx.Value("auth").(*AuthContext); ok {
|
||||
return authCtx
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// HasUsers checks if any users exist in the database
|
||||
func (s *AuthService) HasUsers() (bool, error) {
|
||||
var hasUsers bool
|
||||
|
||||
err := s.db.View(func(txn *badger.Txn) error {
|
||||
opts := badger.DefaultIteratorOptions
|
||||
opts.PrefetchValues = false // We only need to check if keys exist
|
||||
iterator := txn.NewIterator(opts)
|
||||
defer iterator.Close()
|
||||
|
||||
// Look for any key starting with "user:"
|
||||
prefix := []byte("user:")
|
||||
for iterator.Seek(prefix); iterator.ValidForPrefix(prefix); iterator.Next() {
|
||||
hasUsers = true
|
||||
return nil // Found at least one user, can exit early
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return hasUsers, err
|
||||
}
|
||||
|
77
auth/cluster.go
Normal file
77
auth/cluster.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// ClusterAuthService handles authentication for inter-cluster communication
|
||||
type ClusterAuthService struct {
|
||||
clusterSecret string
|
||||
logger *logrus.Logger
|
||||
}
|
||||
|
||||
// NewClusterAuthService creates a new cluster authentication service
|
||||
func NewClusterAuthService(clusterSecret string, logger *logrus.Logger) *ClusterAuthService {
|
||||
return &ClusterAuthService{
|
||||
clusterSecret: clusterSecret,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware validates cluster authentication headers
|
||||
func (s *ClusterAuthService) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract authentication headers
|
||||
clusterSecret := r.Header.Get("X-Cluster-Secret")
|
||||
nodeID := r.Header.Get("X-Node-ID")
|
||||
|
||||
// Log authentication attempt
|
||||
s.logger.WithFields(logrus.Fields{
|
||||
"node_id": nodeID,
|
||||
"remote_addr": r.RemoteAddr,
|
||||
"path": r.URL.Path,
|
||||
"method": r.Method,
|
||||
}).Debug("Cluster authentication attempt")
|
||||
|
||||
// Validate cluster secret
|
||||
if clusterSecret == "" {
|
||||
s.logger.WithFields(logrus.Fields{
|
||||
"node_id": nodeID,
|
||||
"remote_addr": r.RemoteAddr,
|
||||
"path": r.URL.Path,
|
||||
}).Warn("Missing X-Cluster-Secret header")
|
||||
http.Error(w, "Unauthorized: Missing cluster secret", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if clusterSecret != s.clusterSecret {
|
||||
s.logger.WithFields(logrus.Fields{
|
||||
"node_id": nodeID,
|
||||
"remote_addr": r.RemoteAddr,
|
||||
"path": r.URL.Path,
|
||||
}).Warn("Invalid cluster secret")
|
||||
http.Error(w, "Unauthorized: Invalid cluster secret", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate node ID is present
|
||||
if nodeID == "" {
|
||||
s.logger.WithFields(logrus.Fields{
|
||||
"remote_addr": r.RemoteAddr,
|
||||
"path": r.URL.Path,
|
||||
}).Warn("Missing X-Node-ID header")
|
||||
http.Error(w, "Unauthorized: Missing node ID", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Authentication successful
|
||||
s.logger.WithFields(logrus.Fields{
|
||||
"node_id": nodeID,
|
||||
"path": r.URL.Path,
|
||||
}).Debug("Cluster authentication successful")
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
@@ -64,4 +64,4 @@ func ValidateJWT(tokenString string) (*JWTClaims, error) {
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
}
|
||||
|
@@ -33,7 +33,7 @@ func (s *AuthService) Middleware(requiredScopes []string, resourceKeyExtractor f
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Authenticate request
|
||||
authCtx, err := s.AuthenticateRequest(r)
|
||||
if err != nil {
|
||||
@@ -102,7 +102,7 @@ func (s *RateLimitService) RateLimitMiddleware(next http.HandlerFunc) http.Handl
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Extract auth context to get user UUID
|
||||
authCtx := GetAuthContext(r.Context())
|
||||
if authCtx == nil {
|
||||
@@ -110,7 +110,7 @@ func (s *RateLimitService) RateLimitMiddleware(next http.HandlerFunc) http.Handl
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Check rate limit
|
||||
allowed, err := s.checkRateLimit(authCtx.UserUUID)
|
||||
if err != nil {
|
||||
@@ -118,31 +118,32 @@ func (s *RateLimitService) RateLimitMiddleware(next http.HandlerFunc) http.Handl
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if !allowed {
|
||||
s.authService.logger.WithFields(logrus.Fields{
|
||||
"user_uuid": authCtx.UserUUID,
|
||||
"limit": s.config.RateLimitRequests,
|
||||
"window": s.config.RateLimitWindow,
|
||||
}).Info("Rate limit exceeded")
|
||||
|
||||
|
||||
// Set rate limit headers
|
||||
w.Header().Set("X-Rate-Limit-Limit", strconv.Itoa(s.config.RateLimitRequests))
|
||||
w.Header().Set("X-Rate-Limit-Window", s.config.RateLimitWindow)
|
||||
|
||||
|
||||
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// isAuthEnabled checks if authentication is enabled (would be passed from config)
|
||||
// isAuthEnabled checks if authentication is enabled from config
|
||||
func (s *AuthService) isAuthEnabled() bool {
|
||||
// This would normally be injected from config, but for now we'll assume enabled
|
||||
// TODO: Inject config dependency
|
||||
return true
|
||||
if s.config != nil {
|
||||
return s.config.AuthEnabled
|
||||
}
|
||||
return true // Default to enabled if no config
|
||||
}
|
||||
|
||||
// Helper method to check rate limits (simplified version)
|
||||
@@ -150,8 +151,8 @@ func (s *RateLimitService) checkRateLimit(userUUID string) (bool, error) {
|
||||
if s.config.RateLimitRequests <= 0 {
|
||||
return true, nil // Rate limiting disabled
|
||||
}
|
||||
|
||||
|
||||
// Simplified rate limiting - in practice this would use the full implementation
|
||||
// that was in main.go with proper window calculations and BadgerDB storage
|
||||
return true, nil // For now, always allow
|
||||
}
|
||||
}
|
||||
|
@@ -15,7 +15,7 @@ func CheckPermission(permissions int, operation string, isOwner, isGroupMember b
|
||||
return (permissions & types.PermGroupCreate) != 0
|
||||
}
|
||||
return (permissions & types.PermOthersCreate) != 0
|
||||
|
||||
|
||||
case "delete":
|
||||
if isOwner {
|
||||
return (permissions & types.PermOwnerDelete) != 0
|
||||
@@ -24,7 +24,7 @@ func CheckPermission(permissions int, operation string, isOwner, isGroupMember b
|
||||
return (permissions & types.PermGroupDelete) != 0
|
||||
}
|
||||
return (permissions & types.PermOthersDelete) != 0
|
||||
|
||||
|
||||
case "write":
|
||||
if isOwner {
|
||||
return (permissions & types.PermOwnerWrite) != 0
|
||||
@@ -33,7 +33,7 @@ func CheckPermission(permissions int, operation string, isOwner, isGroupMember b
|
||||
return (permissions & types.PermGroupWrite) != 0
|
||||
}
|
||||
return (permissions & types.PermOthersWrite) != 0
|
||||
|
||||
|
||||
case "read":
|
||||
if isOwner {
|
||||
return (permissions & types.PermOwnerRead) != 0
|
||||
@@ -42,7 +42,7 @@ func CheckPermission(permissions int, operation string, isOwner, isGroupMember b
|
||||
return (permissions & types.PermGroupRead) != 0
|
||||
}
|
||||
return (permissions & types.PermOthersRead) != 0
|
||||
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func CheckPermission(permissions int, operation string, isOwner, isGroupMember b
|
||||
// CheckUserResourceRelationship determines user relationship to resource
|
||||
func CheckUserResourceRelationship(userUUID string, metadata *types.ResourceMetadata, userGroups []string) (isOwner, isGroupMember bool) {
|
||||
isOwner = (userUUID == metadata.OwnerUUID)
|
||||
|
||||
|
||||
if metadata.GroupUUID != "" {
|
||||
for _, groupUUID := range userGroups {
|
||||
if groupUUID == metadata.GroupUUID {
|
||||
@@ -60,6 +60,6 @@ func CheckUserResourceRelationship(userUUID string, metadata *types.ResourceMeta
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return isOwner, isGroupMember
|
||||
}
|
||||
}
|
||||
|
@@ -16,4 +16,4 @@ func TokenStorageKey(tokenHash string) string {
|
||||
|
||||
func ResourceMetadataKey(resourceKey string) string {
|
||||
return resourceKey + ":metadata"
|
||||
}
|
||||
}
|
||||
|
@@ -82,10 +82,19 @@ func (s *BootstrapService) attemptJoin(seedAddr string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
url := fmt.Sprintf("http://%s/members/join", seedAddr)
|
||||
client := NewAuthenticatedHTTPClient(s.config, 10*time.Second)
|
||||
protocol := GetProtocol(s.config)
|
||||
url := fmt.Sprintf("%s://%s/members/join", protocol, seedAddr)
|
||||
|
||||
resp, err := client.Post(url, "application/json", bytes.NewBuffer(jsonData))
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
s.logger.WithError(err).Error("Failed to create join request")
|
||||
return false
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
AddClusterAuthHeaders(req, s.config)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
s.logger.WithFields(logrus.Fields{
|
||||
"seed": seedAddr,
|
||||
@@ -142,4 +151,4 @@ func (s *BootstrapService) performGradualSync() {
|
||||
}
|
||||
|
||||
s.logger.Info("Gradual sync completed")
|
||||
}
|
||||
}
|
||||
|
@@ -17,13 +17,13 @@ import (
|
||||
|
||||
// GossipService handles gossip protocol operations
|
||||
type GossipService struct {
|
||||
config *types.Config
|
||||
members map[string]*types.Member
|
||||
membersMu sync.RWMutex
|
||||
logger *logrus.Logger
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
config *types.Config
|
||||
members map[string]*types.Member
|
||||
membersMu sync.RWMutex
|
||||
logger *logrus.Logger
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewGossipService creates a new gossip service
|
||||
@@ -44,7 +44,7 @@ func (s *GossipService) Start() {
|
||||
s.logger.Info("Clustering disabled, skipping gossip routine")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
s.wg.Add(1)
|
||||
go s.gossipRoutine()
|
||||
}
|
||||
@@ -181,11 +181,20 @@ func (s *GossipService) gossipWithPeer(peer *types.Member) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Send HTTP request to peer
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
url := fmt.Sprintf("http://%s/members/gossip", peer.Address)
|
||||
// Send HTTP request to peer with cluster authentication
|
||||
client := NewAuthenticatedHTTPClient(s.config, 5*time.Second)
|
||||
protocol := GetProtocol(s.config)
|
||||
url := fmt.Sprintf("%s://%s/members/gossip", protocol, peer.Address)
|
||||
|
||||
resp, err := client.Post(url, "application/json", bytes.NewBuffer(jsonData))
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
s.logger.WithError(err).Error("Failed to create gossip request")
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
AddClusterAuthHeaders(req, s.config)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
s.logger.WithFields(logrus.Fields{
|
||||
"peer": peer.Address,
|
||||
@@ -300,4 +309,4 @@ func (s *GossipService) MergeMemberList(remoteMembers []types.Member, selfNodeID
|
||||
func (s *GossipService) GetJoinedTimestamp() int64 {
|
||||
// This should be implemented by the server that uses this service
|
||||
return time.Now().UnixMilli()
|
||||
}
|
||||
}
|
||||
|
43
cluster/http_client.go
Normal file
43
cluster/http_client.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"kvs/types"
|
||||
)
|
||||
|
||||
// NewAuthenticatedHTTPClient creates an HTTP client configured for cluster authentication
|
||||
func NewAuthenticatedHTTPClient(config *types.Config, timeout time.Duration) *http.Client {
|
||||
client := &http.Client{
|
||||
Timeout: timeout,
|
||||
}
|
||||
|
||||
// Configure TLS if enabled
|
||||
if config.ClusterTLSEnabled {
|
||||
tlsConfig := &tls.Config{
|
||||
InsecureSkipVerify: config.ClusterTLSSkipVerify,
|
||||
}
|
||||
|
||||
client.Transport = &http.Transport{
|
||||
TLSClientConfig: tlsConfig,
|
||||
}
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
// AddClusterAuthHeaders adds authentication headers to an HTTP request
|
||||
func AddClusterAuthHeaders(req *http.Request, config *types.Config) {
|
||||
req.Header.Set("X-Cluster-Secret", config.ClusterSecret)
|
||||
req.Header.Set("X-Node-ID", config.NodeID)
|
||||
}
|
||||
|
||||
// GetProtocol returns the appropriate protocol (http or https) based on TLS configuration
|
||||
func GetProtocol(config *types.Config) string {
|
||||
if config.ClusterTLSEnabled {
|
||||
return "https"
|
||||
}
|
||||
return "http"
|
||||
}
|
@@ -170,7 +170,7 @@ func (s *MerkleService) BuildSubtreeForRange(startKey, endKey string) (*types.Me
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get KV pairs for subtree: %v", err)
|
||||
}
|
||||
|
||||
|
||||
filteredPairs := FilterPairsByRange(pairs, startKey, endKey)
|
||||
return s.BuildMerkleTreeFromPairs(filteredPairs)
|
||||
}
|
||||
}
|
||||
|
109
cluster/sync.go
109
cluster/sync.go
@@ -51,11 +51,11 @@ func (s *SyncService) Start() {
|
||||
s.logger.Info("Clustering disabled, skipping sync routines")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Start sync routine
|
||||
s.wg.Add(1)
|
||||
go s.syncRoutine()
|
||||
|
||||
|
||||
// Start Merkle tree rebuild routine
|
||||
s.wg.Add(1)
|
||||
go s.merkleTreeRebuildRoutine()
|
||||
@@ -172,9 +172,9 @@ func (s *SyncService) performMerkleSync() {
|
||||
// 2. Compare roots and start recursive diffing if they differ
|
||||
if !bytes.Equal(localRoot.Hash, remoteRoot.Hash) {
|
||||
s.logger.WithFields(logrus.Fields{
|
||||
"peer": peer.Address,
|
||||
"local_root": hex.EncodeToString(localRoot.Hash),
|
||||
"remote_root": hex.EncodeToString(remoteRoot.Hash),
|
||||
"peer": peer.Address,
|
||||
"local_root": hex.EncodeToString(localRoot.Hash),
|
||||
"remote_root": hex.EncodeToString(remoteRoot.Hash),
|
||||
}).Info("Merkle roots differ, starting recursive diff")
|
||||
s.diffMerkleTreesRecursive(peer.Address, localRoot, remoteRoot)
|
||||
} else {
|
||||
@@ -186,10 +186,17 @@ func (s *SyncService) performMerkleSync() {
|
||||
|
||||
// requestMerkleRoot requests the Merkle root from a peer
|
||||
func (s *SyncService) requestMerkleRoot(peerAddress string) (*types.MerkleRootResponse, error) {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
url := fmt.Sprintf("http://%s/merkle_tree/root", peerAddress)
|
||||
client := NewAuthenticatedHTTPClient(s.config, 10*time.Second)
|
||||
protocol := GetProtocol(s.config)
|
||||
url := fmt.Sprintf("%s://%s/merkle_tree/root", protocol, peerAddress)
|
||||
|
||||
resp, err := client.Get(url)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
AddClusterAuthHeaders(req, s.config)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -216,7 +223,7 @@ func (s *SyncService) diffMerkleTreesRecursive(peerAddress string, localNode, re
|
||||
// Hashes differ, need to go deeper.
|
||||
// Request children from the remote peer for the current range.
|
||||
req := types.MerkleTreeDiffRequest{
|
||||
ParentNode: *remoteNode, // We are asking the remote peer about its children for this range
|
||||
ParentNode: *remoteNode, // We are asking the remote peer about its children for this range
|
||||
LocalHash: localNode.Hash, // Our hash for this range
|
||||
}
|
||||
|
||||
@@ -294,10 +301,17 @@ func (s *SyncService) handleLeafLevelDiff(peerAddress string, keys []string, loc
|
||||
|
||||
// fetchSingleKVFromPeer fetches a single KV pair from a peer
|
||||
func (s *SyncService) fetchSingleKVFromPeer(peerAddress, path string) (*types.StoredValue, error) {
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
url := fmt.Sprintf("http://%s/kv/%s", peerAddress, path)
|
||||
client := NewAuthenticatedHTTPClient(s.config, 5*time.Second)
|
||||
protocol := GetProtocol(s.config)
|
||||
url := fmt.Sprintf("%s://%s/kv/%s", protocol, peerAddress, path)
|
||||
|
||||
resp, err := client.Get(url)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
AddClusterAuthHeaders(req, s.config)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -398,14 +412,14 @@ func (s *SyncService) resolveConflict(key string, local, remote *types.StoredVal
|
||||
|
||||
// Timestamps are equal - need sophisticated conflict resolution
|
||||
s.logger.WithField("key", key).Info("Timestamp collision detected, applying oldest-node rule")
|
||||
|
||||
|
||||
// Get cluster members to determine which node is older
|
||||
members := s.gossipService.GetMembers()
|
||||
|
||||
|
||||
// Find the local node and the remote node in membership
|
||||
var localMember, remoteMember *types.Member
|
||||
localNodeID := s.config.NodeID
|
||||
|
||||
|
||||
for _, member := range members {
|
||||
if member.ID == localNodeID {
|
||||
localMember = member
|
||||
@@ -414,10 +428,17 @@ func (s *SyncService) resolveConflict(key string, local, remote *types.StoredVal
|
||||
remoteMember = member
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// If we can't find membership info, fall back to UUID comparison for deterministic result
|
||||
if localMember == nil || remoteMember == nil {
|
||||
s.logger.WithField("key", key).Warn("Could not find membership info for conflict resolution, using UUID comparison")
|
||||
s.logger.WithFields(logrus.Fields{
|
||||
"key": key,
|
||||
"peerAddress": peerAddress,
|
||||
"localNodeID": localNodeID,
|
||||
"localMember": localMember != nil,
|
||||
"remoteMember": remoteMember != nil,
|
||||
"totalMembers": len(members),
|
||||
}).Warn("Could not find membership info for conflict resolution, using UUID comparison")
|
||||
if remote.UUID < local.UUID {
|
||||
// Remote UUID lexically smaller (deterministic choice)
|
||||
err := s.storeReplicatedDataWithMetadata(key, remote)
|
||||
@@ -429,41 +450,49 @@ func (s *SyncService) resolveConflict(key string, local, remote *types.StoredVal
|
||||
s.logger.WithField("key", key).Info("Conflict resolved: local data wins (UUID tie-breaker)")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
// Apply oldest-node rule: node with earliest joined_timestamp wins
|
||||
if remoteMember.JoinedTimestamp < localMember.JoinedTimestamp {
|
||||
// Remote node is older, its data wins
|
||||
err := s.storeReplicatedDataWithMetadata(key, remote)
|
||||
if err == nil {
|
||||
s.logger.WithFields(logrus.Fields{
|
||||
"key": key,
|
||||
"local_joined": localMember.JoinedTimestamp,
|
||||
"remote_joined": remoteMember.JoinedTimestamp,
|
||||
"key": key,
|
||||
"local_joined": localMember.JoinedTimestamp,
|
||||
"remote_joined": remoteMember.JoinedTimestamp,
|
||||
}).Info("Conflict resolved: remote data wins (oldest-node rule)")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
// Local node is older or equal, keep local data
|
||||
s.logger.WithFields(logrus.Fields{
|
||||
"key": key,
|
||||
"local_joined": localMember.JoinedTimestamp,
|
||||
"remote_joined": remoteMember.JoinedTimestamp,
|
||||
"key": key,
|
||||
"local_joined": localMember.JoinedTimestamp,
|
||||
"remote_joined": remoteMember.JoinedTimestamp,
|
||||
}).Info("Conflict resolved: local data wins (oldest-node rule)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// requestMerkleDiff requests children hashes or keys for a given node/range from a peer
|
||||
func (s *SyncService) requestMerkleDiff(peerAddress string, req types.MerkleTreeDiffRequest) (*types.MerkleTreeDiffResponse, error) {
|
||||
jsonData, err := json.Marshal(req)
|
||||
func (s *SyncService) requestMerkleDiff(peerAddress string, reqData types.MerkleTreeDiffRequest) (*types.MerkleTreeDiffResponse, error) {
|
||||
jsonData, err := json.Marshal(reqData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
url := fmt.Sprintf("http://%s/merkle_tree/diff", peerAddress)
|
||||
client := NewAuthenticatedHTTPClient(s.config, 10*time.Second)
|
||||
protocol := GetProtocol(s.config)
|
||||
url := fmt.Sprintf("%s://%s/merkle_tree/diff", protocol, peerAddress)
|
||||
|
||||
resp, err := client.Post(url, "application/json", bytes.NewBuffer(jsonData))
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
AddClusterAuthHeaders(req, s.config)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -518,20 +547,28 @@ func (s *SyncService) handleChildrenDiff(peerAddress string, children []types.Me
|
||||
|
||||
// fetchAndStoreRange fetches a range of KV pairs from a peer and stores them locally
|
||||
func (s *SyncService) fetchAndStoreRange(peerAddress string, startKey, endKey string) error {
|
||||
req := types.KVRangeRequest{
|
||||
reqData := types.KVRangeRequest{
|
||||
StartKey: startKey,
|
||||
EndKey: endKey,
|
||||
Limit: 0, // No limit
|
||||
}
|
||||
jsonData, err := json.Marshal(req)
|
||||
jsonData, err := json.Marshal(reqData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second} // Longer timeout for range fetches
|
||||
url := fmt.Sprintf("http://%s/kv_range", peerAddress)
|
||||
client := NewAuthenticatedHTTPClient(s.config, 30*time.Second) // Longer timeout for range fetches
|
||||
protocol := GetProtocol(s.config)
|
||||
url := fmt.Sprintf("%s://%s/kv_range", protocol, peerAddress)
|
||||
|
||||
resp, err := client.Post(url, "application/json", bytes.NewBuffer(jsonData))
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
AddClusterAuthHeaders(req, s.config)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -561,4 +598,4 @@ func (s *SyncService) fetchAndStoreRange(peerAddress string, startKey, endKey st
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
@@ -1,12 +1,14 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"kvs/types"
|
||||
"gopkg.in/yaml.v3"
|
||||
"kvs/types"
|
||||
)
|
||||
|
||||
// Default configuration
|
||||
@@ -27,37 +29,61 @@ func Default() *types.Config {
|
||||
BootstrapMaxAgeHours: 720, // 30 days
|
||||
ThrottleDelayMs: 100,
|
||||
FetchDelayMs: 50,
|
||||
|
||||
|
||||
// Default compression settings
|
||||
CompressionEnabled: true,
|
||||
CompressionLevel: 3, // Balance between performance and compression ratio
|
||||
|
||||
|
||||
// Default TTL and size limit settings
|
||||
DefaultTTL: "0", // No default TTL
|
||||
MaxJSONSize: 1048576, // 1MB default max JSON size
|
||||
|
||||
DefaultTTL: "0", // No default TTL
|
||||
MaxJSONSize: 1048576, // 1MB default max JSON size
|
||||
|
||||
// Default rate limiting settings
|
||||
RateLimitRequests: 100, // 100 requests per window
|
||||
RateLimitWindow: "1m", // 1 minute window
|
||||
|
||||
|
||||
// Default tamper-evident logging settings
|
||||
TamperLogActions: []string{"data_write", "user_create", "auth_failure"},
|
||||
|
||||
|
||||
// Default backup system settings
|
||||
BackupEnabled: true,
|
||||
BackupSchedule: "0 0 * * *", // Daily at midnight
|
||||
BackupPath: "./backups",
|
||||
BackupRetention: 7, // Keep backups for 7 days
|
||||
|
||||
|
||||
// Default feature toggle settings (all enabled by default)
|
||||
AuthEnabled: true,
|
||||
TamperLoggingEnabled: true,
|
||||
ClusteringEnabled: true,
|
||||
RateLimitingEnabled: true,
|
||||
RevisionHistoryEnabled: true,
|
||||
|
||||
// Default anonymous access settings (both disabled by default for security)
|
||||
AllowAnonymousRead: false,
|
||||
AllowAnonymousWrite: false,
|
||||
|
||||
// Default cluster authentication settings (Issue #13)
|
||||
ClusterSecret: generateClusterSecret(),
|
||||
ClusterTLSEnabled: false,
|
||||
ClusterTLSCertFile: "",
|
||||
ClusterTLSKeyFile: "",
|
||||
ClusterTLSSkipVerify: false,
|
||||
}
|
||||
}
|
||||
|
||||
// generateClusterSecret generates a cryptographically secure random cluster secret
|
||||
func generateClusterSecret() string {
|
||||
// Generate 32 bytes (256 bits) of random data
|
||||
randomBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(randomBytes); err != nil {
|
||||
// Fallback to a warning - this should never happen in practice
|
||||
fmt.Fprintf(os.Stderr, "Warning: Failed to generate secure cluster secret: %v\n", err)
|
||||
return ""
|
||||
}
|
||||
// Encode as base64 for easy configuration file storage
|
||||
return base64.StdEncoding.EncodeToString(randomBytes)
|
||||
}
|
||||
|
||||
// Load configuration from file or create default
|
||||
func Load(configPath string) (*types.Config, error) {
|
||||
config := Default()
|
||||
@@ -90,5 +116,13 @@ func Load(configPath string) (*types.Config, error) {
|
||||
return nil, fmt.Errorf("failed to parse config file: %v", err)
|
||||
}
|
||||
|
||||
// Generate cluster secret if not provided and clustering is enabled (Issue #13)
|
||||
if config.ClusteringEnabled && config.ClusterSecret == "" {
|
||||
config.ClusterSecret = generateClusterSecret()
|
||||
fmt.Printf("Warning: No cluster_secret configured. Generated a random secret.\n")
|
||||
fmt.Printf(" To share this secret with other nodes, add it to your config:\n")
|
||||
fmt.Printf(" cluster_secret: %s\n", config.ClusterSecret)
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
}
|
||||
|
87
daemon/daemonize.go
Normal file
87
daemon/daemonize.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// GetLogFilePath returns the log file path for a given config file
|
||||
func GetLogFilePath(configPath string) (string, error) {
|
||||
logDir, err := getLogDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
absConfigPath, err := filepath.Abs(configPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get absolute config path: %w", err)
|
||||
}
|
||||
|
||||
basename := filepath.Base(configPath)
|
||||
name := filepath.Base(filepath.Dir(absConfigPath)) + "_" + basename
|
||||
return filepath.Join(logDir, name+".log"), nil
|
||||
}
|
||||
|
||||
// Daemonize spawns the process as a daemon and returns
|
||||
func Daemonize(configPath string) error {
|
||||
// Get absolute path to the current executable
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get executable path: %w", err)
|
||||
}
|
||||
|
||||
// Get absolute path to config
|
||||
absConfigPath, err := filepath.Abs(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get absolute config path: %w", err)
|
||||
}
|
||||
|
||||
// Check if already running
|
||||
_, running, err := ReadPID(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if instance is running: %w", err)
|
||||
}
|
||||
if running {
|
||||
return fmt.Errorf("instance is already running")
|
||||
}
|
||||
|
||||
// Spawn the process in background with --daemon flag
|
||||
cmd := exec.Command(executable, "--daemon", absConfigPath)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Setsid: true, // Create new session
|
||||
}
|
||||
|
||||
// Redirect stdout/stderr to log file
|
||||
logDir, err := getLogDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get log directory: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(logDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create log directory: %w", err)
|
||||
}
|
||||
|
||||
basename := filepath.Base(configPath)
|
||||
name := filepath.Base(filepath.Dir(absConfigPath)) + "_" + basename
|
||||
logFile := filepath.Join(logDir, name+".log")
|
||||
|
||||
f, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open log file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
cmd.Stdout = f
|
||||
cmd.Stderr = f
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start daemon: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Started KVS instance '%s' (PID will be written by daemon)\n", filepath.Base(configPath))
|
||||
fmt.Printf("Logs: %s\n", logFile)
|
||||
|
||||
return nil
|
||||
}
|
171
daemon/pid.go
Normal file
171
daemon/pid.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// getPIDDir returns the absolute path to the PID directory
|
||||
func getPIDDir() (string, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get user home directory: %w", err)
|
||||
}
|
||||
return filepath.Join(homeDir, ".kvs", "pids"), nil
|
||||
}
|
||||
|
||||
// getLogDir returns the absolute path to the log directory
|
||||
func getLogDir() (string, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get user home directory: %w", err)
|
||||
}
|
||||
return filepath.Join(homeDir, ".kvs", "logs"), nil
|
||||
}
|
||||
|
||||
// GetPIDFilePath returns the PID file path for a given config file
|
||||
func GetPIDFilePath(configPath string) string {
|
||||
pidDir, err := getPIDDir()
|
||||
if err != nil {
|
||||
// Fallback to local directory
|
||||
pidDir = ".kvs/pids"
|
||||
}
|
||||
|
||||
// Extract basename without extension
|
||||
basename := filepath.Base(configPath)
|
||||
name := strings.TrimSuffix(basename, filepath.Ext(basename))
|
||||
|
||||
return filepath.Join(pidDir, name+".pid")
|
||||
}
|
||||
|
||||
// EnsurePIDDir creates the PID directory if it doesn't exist
|
||||
func EnsurePIDDir() error {
|
||||
pidDir, err := getPIDDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.MkdirAll(pidDir, 0755)
|
||||
}
|
||||
|
||||
// WritePID writes the current process PID to a file
|
||||
func WritePID(configPath string) error {
|
||||
if err := EnsurePIDDir(); err != nil {
|
||||
return fmt.Errorf("failed to create PID directory: %w", err)
|
||||
}
|
||||
|
||||
pidFile := GetPIDFilePath(configPath)
|
||||
pid := os.Getpid()
|
||||
|
||||
return os.WriteFile(pidFile, []byte(fmt.Sprintf("%d\n", pid)), 0644)
|
||||
}
|
||||
|
||||
// ReadPID reads the PID from a file and checks if the process is running
|
||||
func ReadPID(configPath string) (int, bool, error) {
|
||||
pidFile := GetPIDFilePath(configPath)
|
||||
|
||||
data, err := os.ReadFile(pidFile)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, false, nil
|
||||
}
|
||||
return 0, false, fmt.Errorf("failed to read PID file: %w", err)
|
||||
}
|
||||
|
||||
pidStr := strings.TrimSpace(string(data))
|
||||
pid, err := strconv.Atoi(pidStr)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("invalid PID in file: %w", err)
|
||||
}
|
||||
|
||||
// Check if process is actually running
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return pid, false, nil
|
||||
}
|
||||
|
||||
// Send signal 0 to check if process exists
|
||||
err = process.Signal(syscall.Signal(0))
|
||||
if err != nil {
|
||||
return pid, false, nil
|
||||
}
|
||||
|
||||
return pid, true, nil
|
||||
}
|
||||
|
||||
// RemovePID removes the PID file
|
||||
func RemovePID(configPath string) error {
|
||||
pidFile := GetPIDFilePath(configPath)
|
||||
err := os.Remove(pidFile)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to remove PID file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListRunningInstances returns a list of running KVS instances
|
||||
func ListRunningInstances() ([]InstanceInfo, error) {
|
||||
var instances []InstanceInfo
|
||||
|
||||
pidDir, err := getPIDDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if PID directory exists
|
||||
if _, err := os.Stat(pidDir); os.IsNotExist(err) {
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(pidDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read PID directory: %w", err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".pid") {
|
||||
continue
|
||||
}
|
||||
|
||||
name := strings.TrimSuffix(entry.Name(), ".pid")
|
||||
configPath := name + ".yaml" // Assume .yaml extension
|
||||
|
||||
pid, running, err := ReadPID(configPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
instances = append(instances, InstanceInfo{
|
||||
Name: name,
|
||||
PID: pid,
|
||||
Running: running,
|
||||
})
|
||||
}
|
||||
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
// InstanceInfo holds information about a KVS instance
|
||||
type InstanceInfo struct {
|
||||
Name string
|
||||
PID int
|
||||
Running bool
|
||||
}
|
||||
|
||||
// StopProcess stops a process by PID
|
||||
func StopProcess(pid int) error {
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find process: %w", err)
|
||||
}
|
||||
|
||||
// Try graceful shutdown first (SIGTERM)
|
||||
if err := process.Signal(syscall.SIGTERM); err != nil {
|
||||
return fmt.Errorf("failed to send SIGTERM: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
323
design_v2.md
323
design_v2.md
@@ -1,323 +0,0 @@
|
||||
# Gossip in GO, lazy syncing K/J database
|
||||
|
||||
## Software Design Document: Clustered Key-Value Store
|
||||
|
||||
### 1. Introduction
|
||||
|
||||
#### 1.1 Goals
|
||||
This document outlines the design for a minimalistic, clustered key-value database system written in Go. The primary goals are:
|
||||
* **Eventual Consistency:** Prioritize availability and partition tolerance over strong consistency.
|
||||
* **Local-First Truth:** Local operations should be fast, with replication happening in the background.
|
||||
* **Gossip-Style Membership:** Decentralized mechanism for nodes to discover and track each other.
|
||||
* **Hierarchical Keys:** Support for structured keys (e.g., `/home/room/closet/socks`).
|
||||
* **Minimalistic Footprint:** Efficient resource usage on servers.
|
||||
* **Simple Configuration & Operation:** Easy to deploy and manage.
|
||||
* **Read-Only Mode:** Ability for nodes to restrict external writes.
|
||||
* **Gradual Bootstrapping:** New nodes integrate smoothly without overwhelming the cluster.
|
||||
* **Sophisticated Conflict Resolution:** Handle timestamp collisions using majority vote, with oldest node as tie-breaker.
|
||||
|
||||
#### 1.2 Non-Goals
|
||||
* Strong (linearizable/serializable) consistency.
|
||||
* Complex querying or indexing beyond key-based lookups and timestamp-filtered UUID lists.
|
||||
* Transaction support across multiple keys.
|
||||
|
||||
### 2. Architecture Overview
|
||||
|
||||
The system will consist of independent Go services (nodes) that communicate via HTTP/REST. Each node will embed a BadgerDB instance for local data storage and manage its own membership list through a gossip protocol. External clients interact with any available node, which then participates in the cluster's eventual consistency model.
|
||||
|
||||
**Key Architectural Principles:**
|
||||
* **Decentralized:** No central coordinator or leader.
|
||||
* **Peer-to-Peer:** Nodes communicate directly with each other for replication and membership.
|
||||
* **API-Driven:** All interactions, both external (clients) and internal (replication), occur over a RESTful HTTP API.
|
||||
|
||||
```
|
||||
+----------------+ +----------------+ +----------------+
|
||||
| Node A | | Node B | | Node C |
|
||||
| (Go Service) | | (Go Service) | | (Go Service) |
|
||||
| | | | | |
|
||||
| +------------+ | | +------------+ | | +------------+ |
|
||||
| | HTTP Server| | <---- | | HTTP Server| | <---- | | HTTP Server| |
|
||||
| | (API) | | ---> | | (API) | | ---> | | (API) | |
|
||||
| +------------+ | | +------------+ | | +------------+ |
|
||||
| | | | | | | | |
|
||||
| +------------+ | | +------------+ | | +------------+ |
|
||||
| | Gossip | | <---> | | Gossip | | <---> | | Gossip | |
|
||||
| | Manager | | | | Manager | | | | Manager | |
|
||||
| +------------+ | | +------------+ | | +------------+ |
|
||||
| | | | | | | | |
|
||||
| +------------+ | | +------------+ | | +------------+ |
|
||||
| | Replication| | <---> | | Replication| | <---> | | Replication| |
|
||||
| | Logic | | | | Logic | | | | Logic | |
|
||||
| +------------+ | | +------------+ | | +------------+ |
|
||||
| | | | | | | | |
|
||||
| +------------+ | | +------------+ | | +------------+ |
|
||||
| | BadgerDB | | | | BadgerDB | | | | BadgerDB | |
|
||||
| | (Local KV) | | | | (Local KV) | | | | (Local KV) | |
|
||||
| +------------+ | | +------------+ | | +------------+ |
|
||||
+----------------+ +----------------+ +----------------+
|
||||
^
|
||||
|
|
||||
+----- External Clients (Interact with any Node's API)
|
||||
```
|
||||
|
||||
### 3. Data Model
|
||||
|
||||
#### 3.1 Logical Data Structure
|
||||
Data is logically stored as a key-value pair, where the key is a hierarchical path and the value is a JSON object. Each pair also carries metadata for consistency and conflict resolution.
|
||||
|
||||
* **Logical Key:** `string` (e.g., `/home/room/closet/socks`)
|
||||
* **Logical Value:** `JSON object` (e.g., `{"count":7,"colors":["blue","red","black"]}`)
|
||||
|
||||
#### 3.2 Internal Storage Structure (BadgerDB)
|
||||
BadgerDB is a flat key-value store. To accommodate hierarchical keys and metadata, the following mapping will be used:
|
||||
|
||||
* **BadgerDB Key:** The full logical key path, with the leading `/kv/` prefix removed. Path segments will be separated by `/`. **No leading `/` will be stored in the BadgerDB key.**
|
||||
* Example: For logical key `/kv/home/room/closet/socks`, the BadgerDB key will be `home/room/closet/socks`.
|
||||
|
||||
* **BadgerDB Value:** A marshaled JSON object containing the `uuid`, `timestamp`, and the actual `data` JSON object. This allows for consistent versioning and conflict resolution.
|
||||
|
||||
```json
|
||||
// Example BadgerDB Value (marshaled JSON string)
|
||||
{
|
||||
"uuid": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
|
||||
"timestamp": 1672531200000, // Unix timestamp in milliseconds
|
||||
"data": {
|
||||
"count": 7,
|
||||
"colors": ["blue", "red", "black"]
|
||||
}
|
||||
}
|
||||
``` * **`uuid` (string):** A UUIDv4, unique identifier for this specific version of the data.
|
||||
* **`timestamp` (int64):** Unix timestamp representing the time of the last modification. **This will be in milliseconds since epoch**, providing higher precision and reducing collision risk. This is the primary mechanism for conflict resolution ("newest data wins").
|
||||
* **`data` (JSON object):** The actual user-provided JSON payload.
|
||||
|
||||
### 4. API Endpoints
|
||||
|
||||
All endpoints will communicate over HTTP/1.1 and utilize JSON for request/response bodies.
|
||||
|
||||
#### 4.1 `/kv/` Endpoints (Data Operations - External/Internal)
|
||||
|
||||
These endpoints are for direct key-value manipulation by external clients and are also used internally by nodes when fetching full data during replication.
|
||||
|
||||
* **`GET /kv/{path}`**
|
||||
* **Description:** Retrieves the JSON object associated with the given hierarchical key path.
|
||||
* **Request:** No body.
|
||||
* **Responses:**
|
||||
* `200 OK`: `Content-Type: application/json` with the stored JSON object.
|
||||
* `404 Not Found`: If the key does not exist.
|
||||
* `500 Internal Server Error`: For server-side issues (e.g., BadgerDB error).
|
||||
* **Example:** `GET /kv/home/room/closet/socks` -> `{"count":7,"colors":["blue","red","black"]}`
|
||||
|
||||
* **`PUT /kv/{path}`**
|
||||
* **Description:** Creates or updates a JSON object at the given path. This operation will internally generate a new UUIDv4 and assign the current Unix timestamp (milliseconds) to the stored value.
|
||||
* **Request:**
|
||||
* `Content-Type: application/json`
|
||||
* Body: The JSON object to store.
|
||||
* **Responses:**
|
||||
* `200 OK` (Update) or `201 Created` (New): On success, returns `{"uuid": "new-uuid", "timestamp": new-timestamp_ms}`.
|
||||
* `400 Bad Request`: If the request body is not valid JSON.
|
||||
* `403 Forbidden`: If the node is in "read-only" mode and the request's origin is not a recognized cluster member (checked via IP/hostname).
|
||||
* `500 Internal Server Error`: For server-side issues.
|
||||
* **Example:** `PUT /kv/settings/theme` with body `{"color":"dark","font_size":14}` -> `{"uuid": "...", "timestamp": ...}`
|
||||
|
||||
* **`DELETE /kv/{path}`**
|
||||
* **Description:** Deletes the key-value pair at the given path.
|
||||
* **Request:** No body.
|
||||
* **Responses:**
|
||||
* `204 No Content`: On successful deletion.
|
||||
* `404 Not Found`: If the key does not exist.
|
||||
* `403 Forbidden`: If the node is in "read-only" mode and the request is not from a recognized cluster member.
|
||||
* `500 Internal Server Error`: For server-side issues.
|
||||
|
||||
#### 4.2 `/members/` Endpoints (Membership & Internal Replication)
|
||||
|
||||
These endpoints are primarily for internal communication between cluster nodes, managing membership and facilitating data synchronization.
|
||||
|
||||
* **`GET /members/`**
|
||||
* **Description:** Returns a list of known active members in the cluster. This list is maintained locally by each node based on the gossip protocol.
|
||||
* **Request:** No body.
|
||||
* **Responses:**
|
||||
* `200 OK`: `Content-Type: application/json` with a JSON array of member details.
|
||||
```json
|
||||
[
|
||||
{"id": "node-alpha", "address": "192.168.1.10:8080", "last_seen": 1672531200000, "joined_timestamp": 1672530000000},
|
||||
{"id": "node-beta", "address": "192.168.1.11:8080", "last_seen": 1672531205000, "joined_timestamp": 1672530100000}
|
||||
]
|
||||
```
|
||||
* `id` (string): Unique identifier for the node.
|
||||
* `address` (string): `host:port` of the node's API endpoint.
|
||||
* `last_seen` (int64): Unix timestamp (milliseconds) of when this node was last successfully contacted or heard from.
|
||||
* `joined_timestamp` (int64): Unix timestamp (milliseconds) of when this node first joined the cluster. This is crucial for tie-breaking conflicts.
|
||||
* `500 Internal Server Error`: For server-side issues.
|
||||
|
||||
* **`POST /members/join`**
|
||||
* **Description:** Used by a new node to announce its presence and attempt to join the cluster. Existing nodes use this to update their member list and respond with their current view of the cluster.
|
||||
* **Request:**
|
||||
* `Content-Type: application/json`
|
||||
* Body:
|
||||
```json
|
||||
{"id": "node-gamma", "address": "192.168.1.12:8080", "joined_timestamp": 1672532000000}
|
||||
```
|
||||
* `joined_timestamp` will be set by the joining node (its startup time).
|
||||
* **Responses:**
|
||||
* `200 OK`: Acknowledgment, returning the current list of known members to the joining node (same format as `GET /members/`).
|
||||
* `400 Bad Request`: If the request body is malformed.
|
||||
* `500 Internal Server Error`: For server-side issues.
|
||||
|
||||
* **`DELETE /members/leave` (Optional, for graceful shutdown)**
|
||||
* **Description:** A member can proactively announce its departure from the cluster. This allows other nodes to quickly mark it as inactive.
|
||||
* **Request:**
|
||||
* `Content-Type: application/json`
|
||||
* Body: `{"id": "node-gamma"}`
|
||||
* **Responses:**
|
||||
* `204 No Content`: On successful processing.
|
||||
* `400 Bad Request`: If the request body is malformed.
|
||||
* `500 Internal Server Error`: For server-side issues.
|
||||
|
||||
* **`POST /members/pairs_by_time` (Internal/Replication Endpoint)**
|
||||
* **Description:** Used by other cluster members to request a list of key paths, their UUIDs, and their timestamps within a specified time range, optionally filtered by a key prefix. This is critical for both gradual bootstrapping and the regular 5-minute synchronization.
|
||||
* **Request:**
|
||||
* `Content-Type: application/json`
|
||||
* Body:
|
||||
```json
|
||||
{
|
||||
"start_timestamp": 1672531200000, // Unix milliseconds (inclusive)
|
||||
"end_timestamp": 1672617600000, // Unix milliseconds (exclusive), or 0 for "up to now"
|
||||
"limit": 15, // Max number of pairs to return
|
||||
"prefix": "home/room/" // Optional: filter by BadgerDB key prefix
|
||||
}
|
||||
```
|
||||
* `start_timestamp`: Earliest timestamp for data to be included.
|
||||
* `end_timestamp`: Latest timestamp (exclusive). If `0` or omitted, it implies "up to the current time".
|
||||
* `limit`: **Fixed at 15** for this design, to control batch size during sync.
|
||||
* `prefix`: Optional, to filter keys by a common BadgerDB key prefix.
|
||||
* **Responses:**
|
||||
* `200 OK`: `Content-Type: application/json` with a JSON array of objects:
|
||||
```json
|
||||
[
|
||||
{"path": "home/room/closet/socks", "uuid": "...", "timestamp": 1672531200000},
|
||||
{"path": "users/john/profile", "uuid": "...", "timestamp": 1672531205000}
|
||||
]
|
||||
```
|
||||
* `204 No Content`: If no data matches the criteria.
|
||||
* `400 Bad Request`: If request body is malformed or timestamps are invalid.
|
||||
* `500 Internal Server Error`: For server-side issues.
|
||||
|
||||
### 5. BadgerDB Integration
|
||||
|
||||
BadgerDB will be used as the embedded, local, single-node key-value store.
|
||||
|
||||
* **Key Storage:** As described in section 3.2, the HTTP path (without `/kv/` prefix and no leading `/`) will directly map to the BadgerDB key.
|
||||
* **Value Storage:** Values will be marshaled JSON objects (`uuid`, `timestamp`, `data`).
|
||||
* **Timestamp Indexing (for `pairs_by_time`):** To efficiently query by timestamp, a manual secondary index will be maintained. Each `PUT` operation will write two BadgerDB entries:
|
||||
1. The primary data entry: `{badger_key}` -> `{uuid, timestamp, data}`.
|
||||
2. A secondary timestamp index entry: `_ts:{timestamp_ms}:{badger_key}` -> `{uuid}`.
|
||||
* The `_ts` prefix ensures these index keys are grouped and don't conflict with data keys.
|
||||
* The timestamp (milliseconds) ensures lexicographical sorting by time.
|
||||
* The `badger_key` in the index key allows for uniqueness and points back to the main data.
|
||||
* The value can simply be the `uuid` or even an empty string if only the key is needed. Storing the `uuid` here is useful for direct lookups.
|
||||
* **`DELETE` Operations:** A `DELETE /kv/{path}` will remove both the primary data entry and its corresponding secondary index entry from BadgerDB.
|
||||
|
||||
### 6. Clustering and Consistency
|
||||
|
||||
#### 6.1 Membership Management (Gossip Protocol)
|
||||
* Each node maintains a local list of known cluster members (Node ID, Address, Last Seen Timestamp, Joined Timestamp).
|
||||
* Every node will randomly pick a time **between 1-2 minutes** after its last check-up to initiate a gossip round.
|
||||
* In a gossip round, the node randomly selects a subset of its healthy known members (e.g., 1-3 nodes) and performs a "gossip exchange":
|
||||
1. It sends its current local member list to the selected peers.
|
||||
2. Peers merge the received list with their own, updating `last_seen` timestamps for existing members and adding new ones.
|
||||
3. If a node fails to respond to multiple gossip attempts, it is eventually marked as "suspected down" and then "dead" after a configurable timeout.
|
||||
|
||||
#### 6.2 Data Replication (Periodic Syncs)
|
||||
* The system uses two types of data synchronization:
|
||||
1. **Regular 5-Minute Sync:** Catching up on recent changes.
|
||||
2. **Catch-Up Sync (2-Minute Cycles):** For nodes that detect they are significantly behind.
|
||||
|
||||
* **Regular 5-Minute Sync:**
|
||||
* Every **5 minutes**, each node initiates a data synchronization cycle.
|
||||
* It selects a random healthy peer.
|
||||
* It sends `POST /members/pairs_by_time` to the peer, requesting **the 15 latest UUIDs** (by setting `limit: 15` and `end_timestamp: current_time_ms`, with `start_timestamp: 0` or a very old value to ensure enough items are considered).
|
||||
* The remote node responds with its 15 latest (path, uuid, timestamp) pairs.
|
||||
* The local node compares these with its own latest 15. If it finds any data it doesn't have, or an older version of data it does have, it will fetch the full data via `GET /kv/{path}` and update its local store.
|
||||
* If the local node detects it's significantly behind (e.g., many of the remote node's latest 15 UUIDs are missing or much newer locally, indicating a large gap), it triggers the **Catch-Up Sync**.
|
||||
|
||||
* **Catch-Up Sync (2-Minute Cycles):**
|
||||
* This mode is activated when a node determines it's behind its peers (e.g., during the 5-minute sync or bootstrapping).
|
||||
* It runs every **2 minutes** (ensuring it doesn't align with the 5-minute sync).
|
||||
* The node identifies the `oldest_known_timestamp_among_peers_latest_15` from its last regular sync.
|
||||
* It then sends `POST /members/pairs_by_time` to a random healthy peer, requesting **15 UUIDs older than that timestamp** (e.g., `end_timestamp: oldest_known_timestamp_ms`, `limit: 15`, `start_timestamp: 0` or further back).
|
||||
* It continuously iterates backwards in time in 2-minute cycles, progressively asking for older sets of 15 UUIDs until it has caught up to a reasonable historical depth (e.g., configured `BOOTSTRAP_MAX_AGE_HOURS`).
|
||||
* **History Depth:** The system aims to keep track of **at least 3 revisions per path** for conflict resolution and eventually for versioning. The `BOOTSTRAP_MAX_AGE_MILLIS` (defaulting to 30 days) governs how far back in time a node will actively fetch during a full sync.
|
||||
|
||||
#### 6.3 Conflict Resolution
|
||||
When two nodes have different versions of the same key (same BadgerDB key), the conflict resolution logic is applied:
|
||||
|
||||
1. **Timestamp Wins:** The data with the **most recent `timestamp` (Unix milliseconds)** is considered the correct version.
|
||||
2. **Timestamp Collision (Tie-Breaker):** If two conflicting versions have the **exact same `timestamp`**:
|
||||
* **Majority Vote:** The system will query a quorum of healthy peers (`GET /kv/{path}` or an internal check for UUID/timestamp) to see which UUID/timestamp pair the majority holds. The version held by the majority wins.
|
||||
* **Oldest Node Priority (Tie-Breaker for Majority):** If there's an even number of nodes, and thus a tie in the majority vote (e.g., 2 nodes say version A, 2 nodes say version B), the version held by the node with the **oldest `joined_timestamp`** (i.e., the oldest active member in the cluster) takes precedence. This provides a deterministic tie-breaker.
|
||||
* *Implementation Note:* For majority vote, a node might need to request the `{"uuid", "timestamp"}` pairs for a specific `path` from multiple peers. This implies an internal query mechanism or aggregating responses from `pairs_by_time` for the specific key.
|
||||
|
||||
### 7. Bootstrapping New Nodes (Gradual Full Sync)
|
||||
|
||||
This process is initiated when a new node starts up and has no existing data or member list.
|
||||
|
||||
1. **Seed Node Configuration:** The new node must be configured with a list of initial `seed_nodes` (e.g., `["host1:port", "host2:port"]`).
|
||||
2. **Join Request:** The new node attempts to `POST /members/join` to one of its configured seed nodes, providing its own `id`, `address`, and its `joined_timestamp` (its startup time).
|
||||
3. **Member List Discovery:** Upon a successful join, the seed node responds with its current list of known cluster members. The new node populates its local member list.
|
||||
4. **Gradual Data Synchronization Loop (Catch-Up Mode):**
|
||||
* The new node sets its `current_end_timestamp = current_time_ms`.
|
||||
* It defines a `sync_batch_size` (e.g., 15 UUIDs per request, as per `pairs_by_time` `limit`).
|
||||
* It also defines a `throttle_delay` (e.g., 100ms between `pairs_by_time` requests to different peers) and a `fetch_delay` (e.g., 50ms between individual `GET /kv/{path}` requests for full data).
|
||||
* **Loop backwards in time:**
|
||||
* The node determines the `oldest_timestamp_fetched` from its *last* batch of `sync_batch_size` items. Initially, this would be `current_time_ms`.
|
||||
* Randomly pick a healthy peer from its member list.
|
||||
* Send `POST /members/pairs_by_time` to the peer with `end_timestamp: oldest_timestamp_fetched`, `limit: sync_batch_size`, and `start_timestamp: 0`. This asks for 15 items *older than* the oldest one just processed.
|
||||
* Process the received `{"path", "uuid", "timestamp"}` pairs:
|
||||
* For each remote pair, it fetches its local version from BadgerDB.
|
||||
* **Conflict Resolution:** Apply the logic from section 6.3. If local data is missing or older, initiate a `GET /kv/{path}` to fetch the full data and store it.
|
||||
* **Throttling:**
|
||||
* Wait for `throttle_delay` after each `pairs_by_time` request.
|
||||
* Wait for `fetch_delay` after each individual `GET /kv/{path}` request for full data.
|
||||
* **Termination:** The loop continues until the `oldest_timestamp_fetched` goes below the configured `BOOTSTRAP_MAX_AGE_MILLIS` (defaulting to 30 days ago, configurable value). The node may also terminate if multiple consecutive `pairs_by_time` queries return no new (older) data.
|
||||
5. **Full Participation:** Once the gradual sync is complete, the node fully participates in the regular 5-minute replication cycles and accepts external client writes (if not in read-only mode). During the sync, the node will operate in a `syncing` mode, rejecting external client writes with `503 Service Unavailable`.
|
||||
|
||||
### 8. Operational Modes
|
||||
|
||||
* **Normal Mode:** Full read/write capabilities, participates in all replication and gossip activities.
|
||||
* **Read-Only Mode:**
|
||||
* Node will reject `PUT` and `DELETE` requests from **external clients** with a `403 Forbidden` status.
|
||||
* It will **still accept** `PUT` and `DELETE` operations that originate from **recognized cluster members** during replication, allowing it to remain eventually consistent.
|
||||
* `GET` requests are always allowed.
|
||||
* This mode is primarily for reducing write load or protecting data on specific nodes.
|
||||
* **Syncing Mode (Internal during Bootstrap):**
|
||||
* While a new node is undergoing its initial gradual sync, it operates in this internal mode.
|
||||
* External `PUT`/`DELETE` requests will be **rejected with `503 Service Unavailable`**.
|
||||
* Internal replication from other members is fully active.
|
||||
|
||||
### 9. Logging
|
||||
|
||||
A structured logging library (e.g., `zap` or `logrus`) will be used.
|
||||
|
||||
* **Log Levels:** Support for `DEBUG`, `INFO`, `WARN`, `ERROR`, `FATAL`. Configurable.
|
||||
* **Log Format:** JSON for easy parsing by log aggregators.
|
||||
* **Key Events to Log:**
|
||||
* **Startup/Shutdown:** Server start/stop, configuration loaded.
|
||||
* **API Requests:** Incoming HTTP request details (method, path, client IP, status code, duration).
|
||||
* **BadgerDB Operations:** Errors during put/get/delete, database open/close, secondary index operations.
|
||||
* **Membership:** Node joined/left, gossip rounds initiated/received, member status changes (up, suspected, down), tie-breaker decisions.
|
||||
* **Replication:** Sync cycle start/end, type of sync (regular/catch-up), number of keys compared, number of keys fetched, conflict resolutions (including details of timestamp collision resolution).
|
||||
* **Errors:** Data serialization/deserialization, network errors, unhandled exceptions.
|
||||
* **Operational Mode Changes:** Entering/exiting read-only mode, syncing mode.
|
||||
|
||||
### 10. Future Work (Rough Order of Priority)
|
||||
|
||||
These items are considered out of scope for the initial design but are planned for future versions.
|
||||
|
||||
* **Authentication/Authorization (Before First Release):** Implement robust authentication for API endpoints (e.g., API keys, mTLS) and potentially basic authorization for access to `kv` paths.
|
||||
* **Client Libraries/Functions (Bash, Python, Go):** Develop official client libraries or helper functions to simplify interaction with the API for common programming environments.
|
||||
* **Data Compression (gzip):** Implement Gzip compression for data values stored in BadgerDB to reduce storage footprint and potentially improve I/O performance.
|
||||
* **Data Revisions & Simple Backups:**
|
||||
* Hold **at least 3 revisions per path**. This would involve a mechanism to store previous versions of data when a `PUT` occurs, potentially using a separate BadgerDB key namespace (e.g., `_rev:{badger_key}:{timestamp_of_revision}`).
|
||||
* The current `GET /kv/{path}` would continue to return only the latest. A new API might be introduced to fetch specific historical revisions.
|
||||
* Simple backup strategies could leverage these revisions or BadgerDB's native snapshot capabilities.
|
||||
* **Monitoring & Metrics (Grafana Support in v3):** Integrate with a metrics system like Prometheus, exposing key performance indicators (e.g., request rates, error rates, replication lag, BadgerDB stats) for visualization in dashboards like Grafana.
|
@@ -99,4 +99,4 @@ func ExtractKVResourceKey(r *http.Request) string {
|
||||
return path
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
@@ -8,4 +8,4 @@ import (
|
||||
// GetBackupFilename generates a filename for a backup
|
||||
func GetBackupFilename(timestamp time.Time) string {
|
||||
return fmt.Sprintf("kvs-backup-%s.zstd", timestamp.Format("2006-01-02"))
|
||||
}
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
// Package features provides utility functions for KVS authentication, validation,
|
||||
// logging, backup, and other operational features. These functions were extracted
|
||||
// from main.go to improve code organization and maintainability.
|
||||
package features
|
||||
package features
|
||||
|
@@ -5,4 +5,4 @@ import "fmt"
|
||||
// GetRateLimitKey generates the storage key for rate limiting
|
||||
func GetRateLimitKey(userUUID string, windowStart int64) string {
|
||||
return fmt.Sprintf("ratelimit:%s:%d", userUUID, windowStart)
|
||||
}
|
||||
}
|
||||
|
@@ -5,4 +5,4 @@ import "fmt"
|
||||
// GetRevisionKey generates the storage key for a specific revision
|
||||
func GetRevisionKey(baseKey string, revision int) string {
|
||||
return fmt.Sprintf("%s:rev:%d", baseKey, revision)
|
||||
}
|
||||
}
|
||||
|
@@ -21,4 +21,4 @@ func GenerateLogSignature(timestamp, action, userUUID, resource string) string {
|
||||
// Concatenate all fields in a deterministic order
|
||||
data := fmt.Sprintf("%s|%s|%s|%s", timestamp, action, userUUID, resource)
|
||||
return utils.HashSHA3512(data)
|
||||
}
|
||||
}
|
||||
|
@@ -21,4 +21,4 @@ func ParseTTL(ttlString string) (time.Duration, error) {
|
||||
}
|
||||
|
||||
return duration, nil
|
||||
}
|
||||
}
|
||||
|
@@ -45,6 +45,7 @@ cleanup() {
|
||||
log_info "Cleaning up test environment..."
|
||||
pkill -f "$BINARY" 2>/dev/null || true
|
||||
rm -rf "$TEST_DIR" 2>/dev/null || true
|
||||
rm -rf "$HOME/.kvs" 2>/dev/null || true # Clean up PID and log files from home dir
|
||||
sleep 2 # Allow processes to fully terminate
|
||||
}
|
||||
|
||||
@@ -53,7 +54,7 @@ wait_for_service() {
|
||||
local port=$1
|
||||
local timeout=${2:-30}
|
||||
local count=0
|
||||
|
||||
|
||||
while [ $count -lt $timeout ]; do
|
||||
if curl -s "http://localhost:$port/health" >/dev/null 2>&1; then
|
||||
return 0
|
||||
@@ -64,6 +65,15 @@ wait_for_service() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Get log file path for a config file (matches daemon naming convention)
|
||||
get_log_file() {
|
||||
local config=$1
|
||||
local abs_path=$(realpath "$config")
|
||||
local basename=$(basename "$config")
|
||||
local dirname=$(basename $(dirname "$abs_path"))
|
||||
echo "$HOME/.kvs/logs/${dirname}_${basename}.log"
|
||||
}
|
||||
|
||||
# Test 1: Build verification
|
||||
test_build() {
|
||||
test_start "Binary build verification"
|
||||
@@ -82,7 +92,7 @@ test_build() {
|
||||
# Test 2: Basic functionality
|
||||
test_basic_functionality() {
|
||||
test_start "Basic functionality test"
|
||||
|
||||
|
||||
# Create basic config
|
||||
cat > basic.yaml <<EOF
|
||||
node_id: "basic-test"
|
||||
@@ -91,21 +101,23 @@ port: 8090
|
||||
data_dir: "./basic_data"
|
||||
seed_nodes: []
|
||||
log_level: "error"
|
||||
allow_anonymous_read: true
|
||||
allow_anonymous_write: true
|
||||
EOF
|
||||
|
||||
# Start node
|
||||
$BINARY basic.yaml >/dev/null 2>&1 &
|
||||
local pid=$!
|
||||
|
||||
|
||||
# Start node using daemon command
|
||||
$BINARY start basic.yaml >/dev/null 2>&1
|
||||
sleep 2
|
||||
|
||||
if wait_for_service 8090; then
|
||||
# Test basic CRUD
|
||||
local put_result=$(curl -s -X PUT http://localhost:8090/kv/test/basic \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"message":"hello world"}')
|
||||
|
||||
|
||||
local get_result=$(curl -s http://localhost:8090/kv/test/basic)
|
||||
local message=$(echo "$get_result" | jq -r '.data.message' 2>/dev/null) # Adjusted jq path
|
||||
|
||||
local message=$(echo "$get_result" | jq -r '.data.message' 2>/dev/null)
|
||||
|
||||
if [ "$message" = "hello world" ]; then
|
||||
log_success "Basic CRUD operations work"
|
||||
else
|
||||
@@ -114,15 +126,18 @@ EOF
|
||||
else
|
||||
log_error "Basic test node failed to start"
|
||||
fi
|
||||
|
||||
kill $pid 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
$BINARY stop basic.yaml >/dev/null 2>&1
|
||||
sleep 1
|
||||
}
|
||||
|
||||
# Test 3: Cluster formation
|
||||
test_cluster_formation() {
|
||||
test_start "2-node cluster formation and Merkle Tree replication"
|
||||
|
||||
|
||||
# Shared cluster secret for authentication (Issue #13)
|
||||
local CLUSTER_SECRET="test-cluster-secret-12345678901234567890"
|
||||
|
||||
# Node 1 config
|
||||
cat > cluster1.yaml <<EOF
|
||||
node_id: "cluster-1"
|
||||
@@ -134,8 +149,11 @@ log_level: "error"
|
||||
gossip_interval_min: 5
|
||||
gossip_interval_max: 10
|
||||
sync_interval: 10
|
||||
allow_anonymous_read: true
|
||||
allow_anonymous_write: true
|
||||
cluster_secret: "$CLUSTER_SECRET"
|
||||
EOF
|
||||
|
||||
|
||||
# Node 2 config
|
||||
cat > cluster2.yaml <<EOF
|
||||
node_id: "cluster-2"
|
||||
@@ -147,25 +165,27 @@ log_level: "error"
|
||||
gossip_interval_min: 5
|
||||
gossip_interval_max: 10
|
||||
sync_interval: 10
|
||||
allow_anonymous_read: true
|
||||
allow_anonymous_write: true
|
||||
cluster_secret: "$CLUSTER_SECRET"
|
||||
EOF
|
||||
|
||||
# Start nodes
|
||||
$BINARY cluster1.yaml >/dev/null 2>&1 &
|
||||
local pid1=$!
|
||||
|
||||
# Start nodes using daemon commands
|
||||
$BINARY start cluster1.yaml >/dev/null 2>&1
|
||||
sleep 2
|
||||
|
||||
if ! wait_for_service 8101; then
|
||||
log_error "Cluster node 1 failed to start"
|
||||
kill $pid1 2>/dev/null || true
|
||||
$BINARY stop cluster1.yaml >/dev/null 2>&1
|
||||
return 1
|
||||
fi
|
||||
|
||||
sleep 2 # Give node 1 a moment to fully initialize
|
||||
$BINARY cluster2.yaml >/dev/null 2>&1 &
|
||||
local pid2=$!
|
||||
|
||||
|
||||
$BINARY start cluster2.yaml >/dev/null 2>&1
|
||||
sleep 2
|
||||
|
||||
if ! wait_for_service 8102; then
|
||||
log_error "Cluster node 2 failed to start"
|
||||
kill $pid1 $pid2 2>/dev/null || true
|
||||
$BINARY stop cluster1.yaml cluster2.yaml >/dev/null 2>&1
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -213,9 +233,9 @@ EOF
|
||||
else
|
||||
log_error "Cluster formation failed (N1 members: $node1_members, N2 members: $node2_members)"
|
||||
fi
|
||||
|
||||
kill $pid1 $pid2 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
$BINARY stop cluster1.yaml cluster2.yaml >/dev/null 2>&1
|
||||
sleep 1
|
||||
}
|
||||
|
||||
# Test 4: Conflict resolution (Merkle Tree based)
|
||||
@@ -224,15 +244,18 @@ EOF
|
||||
# but same path. The Merkle tree sync should then trigger conflict resolution.
|
||||
test_conflict_resolution() {
|
||||
test_start "Conflict resolution test (Merkle Tree based)"
|
||||
|
||||
|
||||
# Create conflicting data using our utility
|
||||
rm -rf conflict1_data conflict2_data 2>/dev/null || true
|
||||
mkdir -p conflict1_data conflict2_data
|
||||
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
if go run test_conflict.go "$TEST_DIR/conflict1_data" "$TEST_DIR/conflict2_data" >/dev/null 2>&1; then
|
||||
if go run test_conflict.go "$TEST_DIR/conflict1_data" "$TEST_DIR/conflict2_data"; then
|
||||
cd "$TEST_DIR"
|
||||
|
||||
|
||||
# Shared cluster secret for authentication (Issue #13)
|
||||
local CLUSTER_SECRET="conflict-cluster-secret-1234567890123"
|
||||
|
||||
# Create configs
|
||||
cat > conflict1.yaml <<EOF
|
||||
node_id: "conflict-1"
|
||||
@@ -241,9 +264,12 @@ port: 8111
|
||||
data_dir: "./conflict1_data"
|
||||
seed_nodes: []
|
||||
log_level: "info"
|
||||
sync_interval: 8
|
||||
sync_interval: 3
|
||||
allow_anonymous_read: true
|
||||
allow_anonymous_write: true
|
||||
cluster_secret: "$CLUSTER_SECRET"
|
||||
EOF
|
||||
|
||||
|
||||
cat > conflict2.yaml <<EOF
|
||||
node_id: "conflict-2"
|
||||
bind_address: "127.0.0.1"
|
||||
@@ -251,19 +277,21 @@ port: 8112
|
||||
data_dir: "./conflict2_data"
|
||||
seed_nodes: ["127.0.0.1:8111"]
|
||||
log_level: "info"
|
||||
sync_interval: 8
|
||||
sync_interval: 3
|
||||
allow_anonymous_read: true
|
||||
allow_anonymous_write: true
|
||||
cluster_secret: "$CLUSTER_SECRET"
|
||||
EOF
|
||||
|
||||
# Start nodes
|
||||
# Start nodes using daemon commands
|
||||
# Node 1 started first, making it "older" for tie-breaker if timestamps are equal
|
||||
"$BINARY" conflict1.yaml >conflict1.log 2>&1 &
|
||||
local pid1=$!
|
||||
|
||||
$BINARY start conflict1.yaml >/dev/null 2>&1
|
||||
sleep 2
|
||||
|
||||
if wait_for_service 8111; then
|
||||
$BINARY start conflict2.yaml >/dev/null 2>&1
|
||||
sleep 2
|
||||
$BINARY conflict2.yaml >conflict2.log 2>&1 &
|
||||
local pid2=$!
|
||||
|
||||
|
||||
if wait_for_service 8112; then
|
||||
# Get initial data (full StoredValue)
|
||||
local node1_initial_full=$(curl -s http://localhost:8111/kv/test/conflict/data)
|
||||
@@ -274,15 +302,39 @@ EOF
|
||||
|
||||
log_info "Initial conflict state: Node1='$node1_initial_msg', Node2='$node2_initial_msg'"
|
||||
|
||||
# Wait for conflict resolution (multiple sync cycles might be needed)
|
||||
# Allow time for cluster formation and gossip protocol to stabilize
|
||||
log_info "Waiting for cluster formation and gossip stabilization..."
|
||||
sleep 20
|
||||
|
||||
# Get final data (full StoredValue)
|
||||
local node1_final_full=$(curl -s http://localhost:8111/kv/test/conflict/data)
|
||||
local node2_final_full=$(curl -s http://localhost:8112/kv/test/conflict/data)
|
||||
|
||||
local node1_final_msg=$(echo "$node1_final_full" | jq -r '.data.message' 2>/dev/null)
|
||||
local node2_final_msg=$(echo "$node2_final_full" | jq -r '.data.message' 2>/dev/null)
|
||||
# Wait for conflict resolution with retry logic (up to 60 seconds)
|
||||
local max_attempts=20
|
||||
local attempt=1
|
||||
local node1_final_msg=""
|
||||
local node2_final_msg=""
|
||||
local node1_final_full=""
|
||||
local node2_final_full=""
|
||||
|
||||
log_info "Waiting for conflict resolution (checking every 3 seconds, max 60 seconds)..."
|
||||
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
sleep 3
|
||||
|
||||
# Get current data from both nodes
|
||||
node1_final_full=$(curl -s http://localhost:8111/kv/test/conflict/data)
|
||||
node2_final_full=$(curl -s http://localhost:8112/kv/test/conflict/data)
|
||||
|
||||
node1_final_msg=$(echo "$node1_final_full" | jq -r '.data.message' 2>/dev/null)
|
||||
node2_final_msg=$(echo "$node2_final_full" | jq -r '.data.message' 2>/dev/null)
|
||||
|
||||
# Check if they've converged
|
||||
if [ "$node1_final_msg" = "$node2_final_msg" ] && [ -n "$node1_final_msg" ] && [ "$node1_final_msg" != "null" ]; then
|
||||
log_info "Conflict resolution achieved after $((attempt * 3)) seconds"
|
||||
break
|
||||
fi
|
||||
|
||||
log_info "Attempt $attempt/$max_attempts: Node1='$node1_final_msg', Node2='$node2_final_msg' (not converged yet)"
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
# Check if they converged
|
||||
if [ "$node1_final_msg" = "$node2_final_msg" ] && [ -n "$node1_final_msg" ]; then
|
||||
@@ -300,8 +352,10 @@ EOF
|
||||
log_error "Resolved data has inconsistent UUID/Timestamp: N1_UUID=$node1_final_uuid, N1_TS=$node1_final_timestamp, N2_UUID=$node2_final_uuid, N2_TS=$node2_final_timestamp"
|
||||
fi
|
||||
|
||||
# Optionally, check logs for conflict resolution messages
|
||||
if grep -q "Conflict resolved" conflict1.log conflict2.log 2>/dev/null; then
|
||||
# Check logs for conflict resolution messages
|
||||
local log1=$(get_log_file conflict1.yaml)
|
||||
local log2=$(get_log_file conflict2.yaml)
|
||||
if grep -q "Conflict resolved" "$log1" "$log2" 2>/dev/null; then
|
||||
log_success "Conflict resolution messages found in logs"
|
||||
else
|
||||
log_error "No 'Conflict resolved' messages found in logs, but data converged."
|
||||
@@ -313,20 +367,244 @@ EOF
|
||||
else
|
||||
log_error "Conflict node 2 failed to start"
|
||||
fi
|
||||
|
||||
kill $pid2 2>/dev/null || true
|
||||
|
||||
$BINARY stop conflict2.yaml >/dev/null 2>&1
|
||||
else
|
||||
log_error "Conflict node 1 failed to start"
|
||||
fi
|
||||
|
||||
kill $pid1 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
$BINARY stop conflict1.yaml >/dev/null 2>&1
|
||||
sleep 1
|
||||
else
|
||||
cd "$TEST_DIR"
|
||||
log_error "Failed to create conflict test data. Ensure test_conflict.go is correct."
|
||||
fi
|
||||
}
|
||||
|
||||
# Test 5: Authentication middleware (Issue #4)
|
||||
test_authentication_middleware() {
|
||||
test_start "Authentication middleware test (Issue #4)"
|
||||
|
||||
# Create auth test config
|
||||
cat > auth_test.yaml <<EOF
|
||||
node_id: "auth-test"
|
||||
bind_address: "127.0.0.1"
|
||||
port: 8095
|
||||
data_dir: "./auth_test_data"
|
||||
seed_nodes: []
|
||||
log_level: "error"
|
||||
auth_enabled: true
|
||||
allow_anonymous_read: false
|
||||
allow_anonymous_write: false
|
||||
EOF
|
||||
|
||||
# Start node using daemon command
|
||||
$BINARY start auth_test.yaml >/dev/null 2>&1
|
||||
sleep 3 # Allow daemon to start and root account creation
|
||||
|
||||
if wait_for_service 8095; then
|
||||
# Extract the token from logs
|
||||
local log_file=$(get_log_file auth_test.yaml)
|
||||
local token=$(grep "Token:" "$log_file" | sed 's/.*Token: //' | tr -d '\n\r')
|
||||
|
||||
if [ -z "$token" ]; then
|
||||
log_error "Failed to extract authentication token from logs"
|
||||
$BINARY stop auth_test.yaml >/dev/null 2>&1
|
||||
return
|
||||
fi
|
||||
|
||||
# Test 1: Admin endpoints should fail without authentication
|
||||
local no_auth_response=$(curl -s -X POST http://localhost:8095/api/users -H "Content-Type: application/json" -d '{"nickname":"test","password":"test"}')
|
||||
if echo "$no_auth_response" | grep -q "Unauthorized"; then
|
||||
log_success "Admin endpoints properly reject unauthenticated requests"
|
||||
else
|
||||
log_error "Admin endpoints should reject unauthenticated requests, got: $no_auth_response"
|
||||
fi
|
||||
|
||||
# Test 2: Admin endpoints should work with valid authentication
|
||||
local auth_response=$(curl -s -X POST http://localhost:8095/api/users -H "Content-Type: application/json" -H "Authorization: Bearer $token" -d '{"nickname":"authtest","password":"authtest"}')
|
||||
if echo "$auth_response" | grep -q "uuid"; then
|
||||
log_success "Admin endpoints work with valid authentication"
|
||||
else
|
||||
log_error "Admin endpoints should work with authentication, got: $auth_response"
|
||||
fi
|
||||
|
||||
# Test 3: KV endpoints should require auth when anonymous access is disabled
|
||||
local kv_no_auth=$(curl -s -X PUT http://localhost:8095/kv/test/auth -H "Content-Type: application/json" -d '{"test":"auth"}')
|
||||
if echo "$kv_no_auth" | grep -q "Unauthorized"; then
|
||||
log_success "KV endpoints properly require authentication when anonymous access disabled"
|
||||
else
|
||||
log_error "KV endpoints should require auth when anonymous access disabled, got: $kv_no_auth"
|
||||
fi
|
||||
|
||||
# Test 4: KV endpoints should work with valid authentication
|
||||
local kv_auth=$(curl -s -X PUT http://localhost:8095/kv/test/auth -H "Content-Type: application/json" -H "Authorization: Bearer $token" -d '{"test":"auth"}')
|
||||
if echo "$kv_auth" | grep -q "uuid\|timestamp" || [ -z "$kv_auth" ]; then
|
||||
log_success "KV endpoints work with valid authentication"
|
||||
else
|
||||
log_error "KV endpoints should work with authentication, got: $kv_auth"
|
||||
fi
|
||||
|
||||
$BINARY stop auth_test.yaml >/dev/null 2>&1
|
||||
sleep 1
|
||||
else
|
||||
log_error "Auth test node failed to start"
|
||||
$BINARY stop auth_test.yaml >/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
|
||||
# Test 6: Resource Metadata Management (Issue #12)
|
||||
test_metadata_management() {
|
||||
test_start "Resource Metadata Management test (Issue #12)"
|
||||
|
||||
# Create metadata test config
|
||||
cat > metadata_test.yaml <<EOF
|
||||
node_id: "metadata-test"
|
||||
bind_address: "127.0.0.1"
|
||||
port: 8096
|
||||
data_dir: "./metadata_test_data"
|
||||
seed_nodes: []
|
||||
log_level: "error"
|
||||
auth_enabled: true
|
||||
allow_anonymous_read: false
|
||||
allow_anonymous_write: false
|
||||
EOF
|
||||
|
||||
# Start node using daemon command
|
||||
$BINARY start metadata_test.yaml >/dev/null 2>&1
|
||||
sleep 3 # Allow daemon to start and root account creation
|
||||
|
||||
if wait_for_service 8096; then
|
||||
# Extract the token from logs
|
||||
local log_file=$(get_log_file metadata_test.yaml)
|
||||
local token=$(grep "Token:" "$log_file" | sed 's/.*Token: //' | tr -d '\n\r')
|
||||
|
||||
if [ -z "$token" ]; then
|
||||
log_error "Failed to extract authentication token from logs"
|
||||
$BINARY stop metadata_test.yaml >/dev/null 2>&1
|
||||
return
|
||||
fi
|
||||
|
||||
# First, create a KV resource
|
||||
curl -s -X PUT http://localhost:8096/kv/test/resource -H "Content-Type: application/json" -H "Authorization: Bearer $token" -d '{"data":"test"}' >/dev/null
|
||||
sleep 1
|
||||
|
||||
# Test 1: Get metadata should fail for non-existent metadata (initially no metadata exists)
|
||||
local get_response=$(curl -s -w "\n%{http_code}" -X GET http://localhost:8096/kv/test/resource/metadata -H "Authorization: Bearer $token")
|
||||
local get_body=$(echo "$get_response" | head -n -1)
|
||||
local get_code=$(echo "$get_response" | tail -n 1)
|
||||
|
||||
if [ "$get_code" = "404" ]; then
|
||||
log_success "GET metadata returns 404 for non-existent metadata"
|
||||
else
|
||||
log_error "GET metadata should return 404 for non-existent metadata, got code: $get_code, body: $get_body"
|
||||
fi
|
||||
|
||||
# Test 2: Update metadata should create new metadata
|
||||
local update_response=$(curl -s -X PUT http://localhost:8096/kv/test/resource/metadata -H "Content-Type: application/json" -H "Authorization: Bearer $token" -d '{"owner_uuid":"test-owner-123","permissions":3840}')
|
||||
if echo "$update_response" | grep -q "owner_uuid"; then
|
||||
log_success "PUT metadata creates metadata successfully"
|
||||
else
|
||||
log_error "PUT metadata should create metadata, got: $update_response"
|
||||
fi
|
||||
|
||||
# Test 3: Get metadata should now return the created metadata
|
||||
local get_response2=$(curl -s -X GET http://localhost:8096/kv/test/resource/metadata -H "Authorization: Bearer $token")
|
||||
if echo "$get_response2" | grep -q "test-owner-123" && echo "$get_response2" | grep -q "3840"; then
|
||||
log_success "GET metadata returns created metadata"
|
||||
else
|
||||
log_error "GET metadata should return created metadata, got: $get_response2"
|
||||
fi
|
||||
|
||||
# Test 4: Update metadata should modify existing metadata
|
||||
local update_response2=$(curl -s -X PUT http://localhost:8096/kv/test/resource/metadata -H "Content-Type: application/json" -H "Authorization: Bearer $token" -d '{"owner_uuid":"new-owner-456"}')
|
||||
if echo "$update_response2" | grep -q "new-owner-456"; then
|
||||
log_success "PUT metadata updates existing metadata"
|
||||
else
|
||||
log_error "PUT metadata should update metadata, got: $update_response2"
|
||||
fi
|
||||
|
||||
# Test 5: Metadata endpoints should require authentication
|
||||
local no_auth=$(curl -s -w "\n%{http_code}" -X GET http://localhost:8096/kv/test/resource/metadata)
|
||||
local no_auth_code=$(echo "$no_auth" | tail -n 1)
|
||||
if [ "$no_auth_code" = "401" ]; then
|
||||
log_success "Metadata endpoints properly require authentication"
|
||||
else
|
||||
log_error "Metadata endpoints should require authentication, got code: $no_auth_code"
|
||||
fi
|
||||
|
||||
$BINARY stop metadata_test.yaml >/dev/null 2>&1
|
||||
sleep 1
|
||||
else
|
||||
log_error "Metadata test node failed to start"
|
||||
$BINARY stop metadata_test.yaml >/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
|
||||
# Test 7: Daemon commands (start, stop, status, restart)
|
||||
test_daemon_commands() {
|
||||
test_start "Daemon command tests (start, stop, status, restart)"
|
||||
|
||||
# Create daemon test config
|
||||
cat > daemon_test.yaml <<EOF
|
||||
node_id: "daemon-test"
|
||||
bind_address: "127.0.0.1"
|
||||
port: 8097
|
||||
data_dir: "./daemon_test_data"
|
||||
seed_nodes: []
|
||||
log_level: "error"
|
||||
allow_anonymous_read: true
|
||||
allow_anonymous_write: true
|
||||
EOF
|
||||
|
||||
# Test 1: Start command
|
||||
$BINARY start daemon_test.yaml >/dev/null 2>&1
|
||||
sleep 3 # Allow daemon to start
|
||||
|
||||
if wait_for_service 8097 5; then
|
||||
log_success "Daemon 'start' command works"
|
||||
|
||||
# Test 2: Status command shows running
|
||||
local status_output=$($BINARY status daemon_test.yaml 2>&1)
|
||||
if echo "$status_output" | grep -q "RUNNING"; then
|
||||
log_success "Daemon 'status' command shows RUNNING"
|
||||
else
|
||||
log_error "Daemon 'status' should show RUNNING, got: $status_output"
|
||||
fi
|
||||
|
||||
# Test 3: Stop command
|
||||
$BINARY stop daemon_test.yaml >/dev/null 2>&1
|
||||
sleep 2
|
||||
|
||||
# Check that service is actually stopped
|
||||
if ! curl -s "http://localhost:8097/health" >/dev/null 2>&1; then
|
||||
log_success "Daemon 'stop' command works"
|
||||
else
|
||||
log_error "Daemon should be stopped but is still responding"
|
||||
fi
|
||||
|
||||
# Test 4: Restart command
|
||||
$BINARY restart daemon_test.yaml >/dev/null 2>&1
|
||||
sleep 3
|
||||
|
||||
if wait_for_service 8097 5; then
|
||||
log_success "Daemon 'restart' command works"
|
||||
|
||||
# Clean up
|
||||
$BINARY stop daemon_test.yaml >/dev/null 2>&1
|
||||
sleep 1
|
||||
else
|
||||
log_error "Daemon 'restart' failed to start service"
|
||||
fi
|
||||
else
|
||||
log_error "Daemon 'start' command failed"
|
||||
fi
|
||||
|
||||
# Ensure cleanup
|
||||
pkill -f "daemon_test.yaml" 2>/dev/null || true
|
||||
sleep 1
|
||||
}
|
||||
|
||||
# Main test execution
|
||||
main() {
|
||||
echo "=================================================="
|
||||
@@ -344,7 +622,10 @@ main() {
|
||||
test_basic_functionality
|
||||
test_cluster_formation
|
||||
test_conflict_resolution
|
||||
|
||||
test_authentication_middleware
|
||||
test_metadata_management
|
||||
test_daemon_commands
|
||||
|
||||
# Results
|
||||
echo "=================================================="
|
||||
echo " Test Results"
|
||||
|
65
issues/2.md
Normal file
65
issues/2.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Issue #2: Update README.md
|
||||
|
||||
**Status:** ✅ **COMPLETED** *(updated during this session)*
|
||||
**Author:** MrKalzu
|
||||
**Created:** 2025-09-12 22:01:34 +03:00
|
||||
**Repository:** https://git.rauhala.info/ryyst/kalzu-value-store/issues/2
|
||||
|
||||
## Description
|
||||
|
||||
"It feels like the readme has lot of expired info after the latest update."
|
||||
|
||||
## Problem
|
||||
|
||||
The project's README file contained outdated information that needed to be revised following recent updates and refactoring.
|
||||
|
||||
## Resolution Status
|
||||
|
||||
**✅ COMPLETED** - The README.md has been comprehensively updated to reflect the current state of the codebase.
|
||||
|
||||
## Updates Made
|
||||
|
||||
### Architecture & Features
|
||||
- ✅ Updated key features to include Merkle Tree sync, JWT authentication, and modular architecture
|
||||
- ✅ Revised architecture diagram to show modular components
|
||||
- ✅ Added authentication and authorization sections
|
||||
- ✅ Updated conflict resolution description
|
||||
|
||||
### Configuration
|
||||
- ✅ Added comprehensive configuration options including feature toggles
|
||||
- ✅ Updated default values to match actual implementation
|
||||
- ✅ Added feature toggle documentation (auth, clustering, compression, etc.)
|
||||
- ✅ Included backup and tamper logging configuration
|
||||
|
||||
### API Documentation
|
||||
- ✅ Added JWT authentication examples
|
||||
- ✅ Updated API endpoints with proper authorization headers
|
||||
- ✅ Added authentication endpoints documentation
|
||||
- ✅ Included Merkle tree and sync endpoints
|
||||
|
||||
### Project Structure
|
||||
- ✅ Completely updated project structure to reflect modular architecture
|
||||
- ✅ Documented all packages (auth/, cluster/, storage/, server/, etc.)
|
||||
- ✅ Updated file organization to match current codebase
|
||||
|
||||
### Development & Testing
|
||||
- ✅ Updated build and test commands
|
||||
- ✅ Added integration test suite documentation
|
||||
- ✅ Updated conflict resolution testing procedures
|
||||
- ✅ Added code quality tools documentation
|
||||
|
||||
### Performance & Limitations
|
||||
- ✅ Updated performance characteristics with Merkle sync improvements
|
||||
- ✅ Revised limitations to reflect implemented features
|
||||
- ✅ Added realistic timing expectations
|
||||
|
||||
## Current Status
|
||||
|
||||
The README.md now accurately reflects:
|
||||
- Current modular architecture
|
||||
- All implemented features and capabilities
|
||||
- Proper configuration options
|
||||
- Updated development workflow
|
||||
- Comprehensive API documentation
|
||||
|
||||
**This issue has been resolved.**
|
71
issues/3.md
Normal file
71
issues/3.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Issue #3: Implement Autogenerated Root Account for Initial Setup
|
||||
|
||||
**Status:** ✅ **COMPLETED**
|
||||
**Author:** MrKalzu
|
||||
**Created:** 2025-09-12 22:17:12 +03:00
|
||||
**Repository:** https://git.rauhala.info/ryyst/kalzu-value-store/issues/3
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The KVS server lacks a mechanism to create an initial administrative user when starting with an empty database and no seed nodes. This makes it impossible to interact with authentication-protected endpoints during initial setup.
|
||||
|
||||
## Current Challenge
|
||||
|
||||
- Empty database + no seed nodes = no way to authenticate
|
||||
- No existing users means no way to create API tokens
|
||||
- Authentication-protected endpoints become inaccessible
|
||||
- Manual database seeding required for initial setup
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
### 1. Detection Logic
|
||||
- Detect empty database condition
|
||||
- Verify no seed nodes are configured
|
||||
- Only trigger on initial startup with empty state
|
||||
|
||||
### 2. Root Account Generation
|
||||
Create a default "root" user with:
|
||||
- **Server-generated UUID**
|
||||
- **Hashed nickname** (e.g., "root")
|
||||
- **Assigned to default "admin" group**
|
||||
- **Full administrative privileges**
|
||||
|
||||
### 3. API Token Creation
|
||||
- Generate API token with administrative scopes
|
||||
- Include all necessary permissions for initial setup
|
||||
- Set reasonable expiration time
|
||||
|
||||
### 4. Secure Token Distribution
|
||||
- **Securely log the token to console** (one-time display)
|
||||
- **Persist user and token in BadgerDB**
|
||||
- **Clear token from memory after logging**
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Relevant Code Sections
|
||||
- `NewServer` function - Add initialization logic
|
||||
- `User`, `Group`, `APIToken` structs - Use existing data structures
|
||||
- Hashing and storage key functions - Leverage existing auth system
|
||||
|
||||
### Proposed Changes (from MrKalzu's comment)
|
||||
- **Added `HasUsers() (bool, error)`** to `auth/auth.go`
|
||||
- **Added "Initial root account setup for empty DB with no seeds"** to `server/server.go`
|
||||
- **Diff file attached** with implementation details
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Token should be displayed only once during startup
|
||||
- Token should have reasonable expiration
|
||||
- Root account should be clearly identified in logs
|
||||
- Consider forcing password change on first use (future enhancement)
|
||||
|
||||
## Benefits
|
||||
|
||||
- Enables zero-configuration initial setup
|
||||
- Provides secure bootstrap process
|
||||
- Eliminates manual database seeding
|
||||
- Supports automated deployment scenarios
|
||||
|
||||
## Dependencies
|
||||
|
||||
This issue blocks **Issue #4** (securing administrative endpoints), as it provides the mechanism for initial administrative access.
|
59
issues/4.md
Normal file
59
issues/4.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Issue #4: Secure User and Group Management Endpoints with Authentication Middleware
|
||||
|
||||
**Status:** Open
|
||||
**Author:** MrKalzu
|
||||
**Created:** 2025-09-12
|
||||
**Assignee:** ryyst
|
||||
**Repository:** https://git.rauhala.info/ryyst/kalzu-value-store/issues/4
|
||||
|
||||
## Description
|
||||
|
||||
**Security Vulnerability:** User, group, and token management API endpoints are currently exposed without authentication, creating a significant security risk.
|
||||
|
||||
## Current Problem
|
||||
|
||||
The following administrative endpoints are accessible without authentication:
|
||||
- User management endpoints (`createUserHandler`, `getUserHandler`, etc.)
|
||||
- Group management endpoints
|
||||
- Token management endpoints
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
### 1. Define Granular Administrative Scopes
|
||||
|
||||
Create specific administrative scopes for fine-grained access control:
|
||||
- `admin:users:create` - Create new users
|
||||
- `admin:users:read` - View user information
|
||||
- `admin:users:update` - Modify user data
|
||||
- `admin:users:delete` - Remove users
|
||||
- `admin:groups:create` - Create new groups
|
||||
- `admin:groups:read` - View group information
|
||||
- `admin:groups:update` - Modify group membership
|
||||
- `admin:groups:delete` - Remove groups
|
||||
- `admin:tokens:create` - Generate API tokens
|
||||
- `admin:tokens:revoke` - Revoke API tokens
|
||||
|
||||
### 2. Apply Authentication Middleware
|
||||
|
||||
Wrap all administrative handlers with `authMiddleware` and specific scope requirements:
|
||||
|
||||
```go
|
||||
// Example implementation
|
||||
router.Handle("/auth/users", authMiddleware("admin:users:create")(createUserHandler))
|
||||
router.Handle("/auth/users/{id}", authMiddleware("admin:users:read")(getUserHandler))
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Depends on Issue #3**: Requires implementation of autogenerated root account for initial setup
|
||||
|
||||
## Security Benefits
|
||||
|
||||
- Prevents unauthorized administrative access
|
||||
- Implements principle of least privilege
|
||||
- Provides audit trail for administrative operations
|
||||
- Protects against privilege escalation attacks
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
**High Priority** - This addresses a critical security vulnerability that could allow unauthorized access to administrative functions.
|
47
issues/5.md
Normal file
47
issues/5.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Issue #5: Add Configuration for Anonymous Read and Write Access to KV Endpoints
|
||||
|
||||
**Status:** Open
|
||||
**Author:** MrKalzu
|
||||
**Created:** 2025-09-12
|
||||
**Repository:** https://git.rauhala.info/ryyst/kalzu-value-store/issues/5
|
||||
|
||||
## Description
|
||||
|
||||
Currently, KV endpoints are publicly accessible without authentication. This issue proposes adding granular control over public access to key-value store functionality.
|
||||
|
||||
## Proposed Configuration Parameters
|
||||
|
||||
Add two new configuration parameters to the `Config` struct:
|
||||
|
||||
1. **`AllowAnonymousRead`** (boolean, default `false`)
|
||||
- Controls whether unauthenticated users can read data
|
||||
|
||||
2. **`AllowAnonymousWrite`** (boolean, default `false`)
|
||||
- Controls whether unauthenticated users can write data
|
||||
|
||||
## Proposed Implementation Changes
|
||||
|
||||
### Modify `setupRoutes` Function
|
||||
- Conditionally apply authentication middleware based on configuration flags
|
||||
|
||||
### Specific Handler Changes
|
||||
- **`getKVHandler`**: Apply auth middleware with "read" scope if `AllowAnonymousRead` is `false`
|
||||
- **`putKVHandler`**: Apply auth middleware with "write" scope if `AllowAnonymousWrite` is `false`
|
||||
- **`deleteKVHandler`**: Always require authentication (no anonymous delete)
|
||||
|
||||
## Goal
|
||||
|
||||
Provide granular control over public access to key-value store functionality while maintaining security for sensitive operations.
|
||||
|
||||
## Use Cases
|
||||
|
||||
- **Public read-only deployments**: Allow anonymous reading for public data
|
||||
- **Public write scenarios**: Allow anonymous data submission (like forms or logs)
|
||||
- **Secure deployments**: Require authentication for all operations
|
||||
- **Mixed access patterns**: Different permissions for read vs write operations
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Delete operations should always require authentication
|
||||
- Consider rate limiting for anonymous access
|
||||
- Audit logging should track anonymous operations differently
|
46
issues/6.md
Normal file
46
issues/6.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Issue #6: Configuration Options to Disable Optional Functionalities
|
||||
|
||||
**Status:** ✅ **COMPLETED**
|
||||
**Author:** MrKalzu
|
||||
**Created:** 2025-09-12
|
||||
**Repository:** https://git.rauhala.info/ryyst/kalzu-value-store/issues/6
|
||||
|
||||
## Description
|
||||
|
||||
Proposes adding configuration options to disable advanced features in the KVS (Key-Value Store) server to allow more flexible and lightweight deployment scenarios.
|
||||
|
||||
## Suggested Disablement Options
|
||||
|
||||
1. **Authentication System** - Disable JWT authentication entirely
|
||||
2. **Tamper-Evident Logging** - Disable cryptographic audit trails
|
||||
3. **Clustering** - Disable gossip protocol and distributed features
|
||||
4. **Rate Limiting** - Disable per-client rate limiting
|
||||
5. **Revision History** - Disable automatic versioning
|
||||
|
||||
## Proposed Implementation
|
||||
|
||||
- Add boolean flags to the Config struct for each feature
|
||||
- Modify server initialization and request handling to respect these flags
|
||||
- Allow conditional compilation/execution of features based on configuration
|
||||
|
||||
## Pros of Proposed Changes
|
||||
|
||||
- Reduce unnecessary overhead for simple deployments
|
||||
- Simplify setup for different deployment needs
|
||||
- Improve performance for specific use cases
|
||||
- Lower resource consumption
|
||||
|
||||
## Cons of Proposed Changes
|
||||
|
||||
- Potential security risks if features are disabled inappropriately
|
||||
- Loss of advanced functionality like audit trails or data recovery
|
||||
- Increased complexity in codebase with conditional feature logic
|
||||
|
||||
## Already Implemented Features
|
||||
|
||||
- Backup System (configurable)
|
||||
- Compression (configurable)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The issue suggests modifying relevant code sections to conditionally enable/disable these features based on configuration, similar to how backup and compression are currently handled.
|
208
main.go
208
main.go
@@ -6,26 +6,90 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"kvs/config"
|
||||
"kvs/daemon"
|
||||
"kvs/server"
|
||||
)
|
||||
|
||||
|
||||
func main() {
|
||||
configPath := "./config.yaml"
|
||||
|
||||
// Simple CLI argument parsing
|
||||
if len(os.Args) > 1 {
|
||||
configPath = os.Args[1]
|
||||
if len(os.Args) < 2 {
|
||||
// No arguments - run in foreground with default config
|
||||
runServer("./config.yaml", false)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is a daemon spawn
|
||||
if os.Args[1] == "--daemon" {
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Fprintf(os.Stderr, "Error: --daemon flag requires config path\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
runServer(os.Args[2], true)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse subcommand
|
||||
command := os.Args[1]
|
||||
|
||||
switch command {
|
||||
case "start":
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Fprintf(os.Stderr, "Usage: kvs start <config>\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
cmdStart(normalizeConfigPath(os.Args[2]))
|
||||
|
||||
case "stop":
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Fprintf(os.Stderr, "Usage: kvs stop <config>\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
cmdStop(normalizeConfigPath(os.Args[2]))
|
||||
|
||||
case "restart":
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Fprintf(os.Stderr, "Usage: kvs restart <config>\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
cmdRestart(normalizeConfigPath(os.Args[2]))
|
||||
|
||||
case "status":
|
||||
if len(os.Args) > 2 {
|
||||
cmdStatusSingle(normalizeConfigPath(os.Args[2]))
|
||||
} else {
|
||||
cmdStatusAll()
|
||||
}
|
||||
|
||||
case "help", "--help", "-h":
|
||||
printHelp()
|
||||
|
||||
default:
|
||||
// Backward compatibility: assume it's a config file path
|
||||
runServer(command, false)
|
||||
}
|
||||
}
|
||||
|
||||
func runServer(configPath string, isDaemon bool) {
|
||||
cfg, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to load configuration: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Write PID file if running as daemon
|
||||
if isDaemon {
|
||||
if err := daemon.WritePID(configPath); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to write PID file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer daemon.RemovePID(configPath)
|
||||
}
|
||||
|
||||
kvServer, err := server.NewServer(cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to create server: %v\n", err)
|
||||
@@ -46,3 +110,135 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func cmdStart(configPath string) {
|
||||
if err := daemon.Daemonize(configPath); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to start: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func cmdStop(configPath string) {
|
||||
pid, running, err := daemon.ReadPID(configPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to read PID: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if !running {
|
||||
fmt.Printf("Instance '%s' is not running\n", configPath)
|
||||
// Clean up stale PID file
|
||||
daemon.RemovePID(configPath)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Stopping instance '%s' (PID %d)...\n", configPath, pid)
|
||||
if err := daemon.StopProcess(pid); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to stop process: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Wait a bit and verify it stopped
|
||||
time.Sleep(1 * time.Second)
|
||||
_, stillRunning, _ := daemon.ReadPID(configPath)
|
||||
if stillRunning {
|
||||
fmt.Printf("Warning: Process may still be running\n")
|
||||
} else {
|
||||
daemon.RemovePID(configPath)
|
||||
fmt.Printf("Stopped successfully\n")
|
||||
}
|
||||
}
|
||||
|
||||
func cmdRestart(configPath string) {
|
||||
// Check if running
|
||||
_, running, err := daemon.ReadPID(configPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to check status: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if running {
|
||||
cmdStop(configPath)
|
||||
// Wait a bit for clean shutdown
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
|
||||
cmdStart(configPath)
|
||||
}
|
||||
|
||||
func cmdStatusSingle(configPath string) {
|
||||
pid, running, err := daemon.ReadPID(configPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to read PID: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if running {
|
||||
fmt.Printf("Instance '%s': RUNNING (PID %d)\n", configPath, pid)
|
||||
} else if pid > 0 {
|
||||
fmt.Printf("Instance '%s': STOPPED (stale PID %d)\n", configPath, pid)
|
||||
} else {
|
||||
fmt.Printf("Instance '%s': STOPPED\n", configPath)
|
||||
}
|
||||
}
|
||||
|
||||
func cmdStatusAll() {
|
||||
instances, err := daemon.ListRunningInstances()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to list instances: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if len(instances) == 0 {
|
||||
fmt.Println("No KVS instances found")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("KVS Instances:")
|
||||
for _, inst := range instances {
|
||||
status := "STOPPED"
|
||||
if inst.Running {
|
||||
status = "RUNNING"
|
||||
}
|
||||
fmt.Printf(" %-20s %s (PID %d)\n", inst.Name, status, inst.PID)
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeConfigPath ensures config path has .yaml extension if not specified
|
||||
func normalizeConfigPath(path string) string {
|
||||
// If path doesn't have an extension, add .yaml
|
||||
if filepath.Ext(path) == "" {
|
||||
return path + ".yaml"
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// getConfigIdentifier returns the identifier for a config (basename without extension)
|
||||
// This is used for PID files and status display
|
||||
func getConfigIdentifier(path string) string {
|
||||
basename := filepath.Base(path)
|
||||
return strings.TrimSuffix(basename, filepath.Ext(basename))
|
||||
}
|
||||
|
||||
func printHelp() {
|
||||
help := `KVS - Distributed Key-Value Store
|
||||
|
||||
Usage:
|
||||
kvs [config.yaml] Run in foreground (default: ./config.yaml)
|
||||
kvs start <config> Start as daemon (.yaml extension optional)
|
||||
kvs stop <config> Stop daemon (.yaml extension optional)
|
||||
kvs restart <config> Restart daemon (.yaml extension optional)
|
||||
kvs status [config] Show status (all instances if no config given)
|
||||
kvs help Show this help
|
||||
|
||||
Examples:
|
||||
kvs # Run with ./config.yaml in foreground
|
||||
kvs node1.yaml # Run with node1.yaml in foreground
|
||||
kvs start node1 # Start node1.yaml as daemon
|
||||
kvs start node1.yaml # Same as above
|
||||
kvs stop node1 # Stop node1 daemon
|
||||
kvs status # Show all running instances
|
||||
kvs status node1 # Show status of node1
|
||||
`
|
||||
fmt.Print(help)
|
||||
}
|
||||
|
291
next_steps.md
291
next_steps.md
@@ -1,291 +0,0 @@
|
||||
# KVS Development Phase 2: Implementation Specification
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document specifies the next development phase for the KVS (Key-Value Store) distributed database. Phase 2 adds authentication, authorization, data management improvements, and basic security features while maintaining backward compatibility with the existing Merkle tree-based replication system.
|
||||
|
||||
## 1. Authentication & Authorization System
|
||||
|
||||
### 1.1 Core Components
|
||||
|
||||
**Users**
|
||||
- Identified by UUID (generated server-side)
|
||||
- Nickname stored as SHA3-512 hash
|
||||
- Can belong to multiple groups
|
||||
- Storage key: `user:<uuid>`
|
||||
|
||||
**Groups**
|
||||
- Identified by UUID (generated server-side)
|
||||
- Group name stored as SHA3-512 hash
|
||||
- Contains list of member user UUIDs
|
||||
- Storage key: `group:<uuid>`
|
||||
|
||||
**API Tokens**
|
||||
- JWT tokens with SHA3-512 hashed storage
|
||||
- 1-hour default expiration (configurable)
|
||||
- Storage key: `token:<sha3-512-hash>`
|
||||
|
||||
### 1.2 Permission Model
|
||||
|
||||
**POSIX-inspired ACL framework** with 12-bit permissions:
|
||||
- 4 bits each for Owner/Group/Others
|
||||
- Operations: Create(C), Delete(D), Write(W), Read(R)
|
||||
- Default permissions: Owner(1111), Group(0110), Others(0010)
|
||||
- Stored as integer bitmask in resource metadata
|
||||
|
||||
**Resource Metadata Schema**:
|
||||
```json
|
||||
{
|
||||
"owner_uuid": "string",
|
||||
"group_uuid": "string",
|
||||
"permissions": 3826, // 12-bit integer
|
||||
"ttl": "24h"
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 API Endpoints
|
||||
|
||||
**User Management**
|
||||
```
|
||||
POST /api/users
|
||||
Body: {"nickname": "string"}
|
||||
Returns: {"uuid": "string"}
|
||||
|
||||
GET /api/users/{uuid}
|
||||
PUT /api/users/{uuid}
|
||||
Body: {"nickname": "string", "groups": ["uuid1", "uuid2"]}
|
||||
DELETE /api/users/{uuid}
|
||||
```
|
||||
|
||||
**Group Management**
|
||||
```
|
||||
POST /api/groups
|
||||
Body: {"groupname": "string", "members": ["uuid1", "uuid2"]}
|
||||
Returns: {"uuid": "string"}
|
||||
|
||||
GET /api/groups/{uuid}
|
||||
PUT /api/groups/{uuid}
|
||||
Body: {"members": ["uuid1", "uuid2"]}
|
||||
DELETE /api/groups/{uuid}
|
||||
```
|
||||
|
||||
**Token Management**
|
||||
```
|
||||
POST /api/tokens
|
||||
Body: {"user_uuid": "string", "scopes": ["read", "write"]}
|
||||
Returns: {"token": "jwt-string", "expires_at": "timestamp"}
|
||||
```
|
||||
|
||||
All endpoints require `Authorization: Bearer <token>` header.
|
||||
|
||||
### 1.4 Implementation Requirements
|
||||
|
||||
- Use `golang.org/x/crypto/sha3` for all hashing
|
||||
- Store token SHA3-512 hash in BadgerDB with TTL
|
||||
- Implement `CheckPermission(userUUID, resourceKey, operation) bool` function
|
||||
- Include user/group data in existing Merkle tree replication
|
||||
- Create migration script for existing data (add default metadata)
|
||||
|
||||
## 2. Database Enhancements
|
||||
|
||||
### 2.1 ZSTD Compression
|
||||
|
||||
**Configuration**:
|
||||
```yaml
|
||||
database:
|
||||
compression_enabled: true
|
||||
compression_level: 3 # 1-19, balance performance/ratio
|
||||
```
|
||||
|
||||
**Implementation**:
|
||||
- Use `github.com/klauspost/compress/zstd`
|
||||
- Compress all JSON values before BadgerDB storage
|
||||
- Decompress on read operations
|
||||
- Optional: Batch recompression of existing data on startup
|
||||
|
||||
### 2.2 TTL (Time-To-Live)
|
||||
|
||||
**Features**:
|
||||
- Per-key TTL support via resource metadata
|
||||
- Global default TTL configuration (optional)
|
||||
- Automatic expiration via BadgerDB's native TTL
|
||||
- TTL applied to main data and revision keys
|
||||
|
||||
**API Integration**:
|
||||
```json
|
||||
// In PUT/POST requests
|
||||
{
|
||||
"data": {...},
|
||||
"ttl": "24h" // Go duration format
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 Revision History
|
||||
|
||||
**Storage Pattern**:
|
||||
- Main data: `data:<key>`
|
||||
- Revisions: `data:<key>:rev:1`, `data:<key>:rev:2`, `data:<key>:rev:3`
|
||||
- Metadata: `data:<key>:metadata` includes `"revisions": [1,2,3]`
|
||||
|
||||
**Rotation Logic**:
|
||||
- On write: rev:1→rev:2, rev:2→rev:3, new→rev:1, delete rev:3
|
||||
- Store up to 3 revisions per key
|
||||
|
||||
**API Endpoints**:
|
||||
```
|
||||
GET /api/data/{key}/history
|
||||
Returns: {"revisions": [{"number": 1, "timestamp": "..."}]}
|
||||
|
||||
GET /api/data/{key}/history/{revision}
|
||||
Returns: StoredValue for specific revision
|
||||
```
|
||||
|
||||
### 2.4 Backup System
|
||||
|
||||
**Configuration**:
|
||||
```yaml
|
||||
backups:
|
||||
enabled: true
|
||||
schedule: "0 0 * * *" # Daily midnight
|
||||
path: "/backups"
|
||||
retention: 7 # days
|
||||
```
|
||||
|
||||
**Implementation**:
|
||||
- Use `github.com/robfig/cron/v3` for scheduling
|
||||
- Create ZSTD-compressed BadgerDB snapshots
|
||||
- Filename format: `kvs-backup-YYYY-MM-DD.zstd`
|
||||
- Automatic cleanup of old backups
|
||||
- Status API: `GET /api/backup/status`
|
||||
|
||||
### 2.5 JSON Size Limits
|
||||
|
||||
**Configuration**:
|
||||
```yaml
|
||||
database:
|
||||
max_json_size: 1048576 # 1MB default
|
||||
```
|
||||
|
||||
**Implementation**:
|
||||
- Check size before compression/storage
|
||||
- Return HTTP 413 if exceeded
|
||||
- Apply to main data and revisions
|
||||
- Log oversized attempts
|
||||
|
||||
## 3. Security Features
|
||||
|
||||
### 3.1 Rate Limiting
|
||||
|
||||
**Configuration**:
|
||||
```yaml
|
||||
rate_limit:
|
||||
requests: 100
|
||||
window: "1m"
|
||||
```
|
||||
|
||||
**Implementation**:
|
||||
- Per-user rate limiting using BadgerDB counters
|
||||
- Key pattern: `ratelimit:<user_uuid>:<window_start>`
|
||||
- Return HTTP 429 when limit exceeded
|
||||
- Counters have TTL equal to window duration
|
||||
|
||||
### 3.2 Tamper-Evident Logs
|
||||
|
||||
**Log Entry Schema**:
|
||||
```json
|
||||
{
|
||||
"timestamp": "2025-09-11T17:29:00Z",
|
||||
"action": "data_write", // Configurable actions
|
||||
"user_uuid": "string",
|
||||
"resource": "string",
|
||||
"signature": "sha3-512 hash" // Hash of all fields
|
||||
}
|
||||
```
|
||||
|
||||
**Storage**:
|
||||
- Key: `log:<timestamp>:<uuid>`
|
||||
- Compressed with ZSTD
|
||||
- Hourly Merkle tree roots: `log:merkle:<timestamp>`
|
||||
- Include in cluster replication
|
||||
|
||||
**Configurable Actions**:
|
||||
```yaml
|
||||
tamper_logs:
|
||||
actions: ["data_write", "user_create", "auth_failure"]
|
||||
```
|
||||
|
||||
## 4. Implementation Phases
|
||||
|
||||
### Phase 2.1: Core Authentication
|
||||
1. Implement user/group storage schema
|
||||
2. Add SHA3-512 hashing utilities
|
||||
3. Create basic CRUD APIs for users/groups
|
||||
4. Implement JWT token generation/validation
|
||||
5. Add authorization middleware
|
||||
|
||||
### Phase 2.2: Data Features
|
||||
1. Add ZSTD compression to BadgerDB operations
|
||||
2. Implement TTL support in resource metadata
|
||||
3. Build revision history system
|
||||
4. Add JSON size validation
|
||||
|
||||
### Phase 2.3: Security & Operations
|
||||
1. Implement rate limiting middleware
|
||||
2. Add tamper-evident logging system
|
||||
3. Build backup scheduling system
|
||||
4. Create migration scripts for existing data
|
||||
|
||||
### Phase 2.4: Integration & Testing
|
||||
1. Integrate auth with existing replication
|
||||
2. End-to-end testing of all features
|
||||
3. Performance benchmarking
|
||||
4. Documentation updates
|
||||
|
||||
## 5. Configuration Example
|
||||
|
||||
```yaml
|
||||
node_id: "node1"
|
||||
bind_address: "127.0.0.1"
|
||||
port: 8080
|
||||
data_dir: "./data"
|
||||
|
||||
database:
|
||||
compression_enabled: true
|
||||
compression_level: 3
|
||||
max_json_size: 1048576
|
||||
default_ttl: "0" # No default TTL
|
||||
|
||||
backups:
|
||||
enabled: true
|
||||
schedule: "0 0 * * *"
|
||||
path: "/backups"
|
||||
retention: 7
|
||||
|
||||
rate_limit:
|
||||
requests: 100
|
||||
window: "1m"
|
||||
|
||||
tamper_logs:
|
||||
actions: ["data_write", "user_create", "auth_failure"]
|
||||
```
|
||||
|
||||
## 6. Migration Strategy
|
||||
|
||||
1. **Backward Compatibility**: All existing APIs remain functional
|
||||
2. **Optional Features**: New features can be disabled via configuration
|
||||
|
||||
|
||||
## 7. Dependencies
|
||||
|
||||
**New Libraries**:
|
||||
- `golang.org/x/crypto/sha3` - SHA3-512 hashing
|
||||
- `github.com/klauspost/compress/zstd` - Compression
|
||||
- `github.com/robfig/cron/v3` - Backup scheduling
|
||||
- `github.com/golang-jwt/jwt/v4` - JWT tokens (recommended)
|
||||
|
||||
**Existing Libraries** (no changes):
|
||||
- `github.com/dgraph-io/badger/v4`
|
||||
- `github.com/google/uuid`
|
||||
- `github.com/gorilla/mux`
|
||||
- `github.com/sirupsen/logrus`
|
||||
|
68
refactor.md
68
refactor.md
@@ -1,68 +0,0 @@
|
||||
# Refactoring Proposal for KVS Main.go
|
||||
|
||||
After analyzing your 3,990-line main.go file, I've identified clear functional areas that can be separated into manageable modules.
|
||||
Here's my comprehensive refactoring proposal:
|
||||
|
||||
Proposed File Structure
|
||||
|
||||
kvs/
|
||||
├── main.go # Entry point + minimal server setup
|
||||
├── config/
|
||||
│ └── config.go # Configuration structures and loading
|
||||
├── types/
|
||||
│ └── types.go # All data structures and type definitions
|
||||
├── auth/
|
||||
│ ├── auth.go # Authentication & authorization logic
|
||||
│ ├── jwt.go # JWT token management
|
||||
│ ├── middleware.go # Auth middleware
|
||||
│ └── permissions.go # Permission checking utilities
|
||||
├── storage/
|
||||
│ ├── storage.go # BadgerDB operations and utilities
|
||||
│ ├── compression.go # ZSTD compression/decompression
|
||||
│ ├── ttl.go # TTL and metadata management
|
||||
│ └── revision.go # Revision history system
|
||||
├── cluster/
|
||||
│ ├── gossip.go # Gossip protocol implementation
|
||||
│ ├── members.go # Member management
|
||||
│ ├── sync.go # Data synchronization
|
||||
│ └── merkle.go # Merkle tree operations
|
||||
├── server/
|
||||
│ ├── server.go # Server struct and core methods
|
||||
│ ├── handlers.go # HTTP request handlers
|
||||
│ ├── routes.go # Route setup
|
||||
│ └── lifecycle.go # Server startup/shutdown logic
|
||||
├── features/
|
||||
│ ├── ratelimit.go # Rate limiting middleware and utilities
|
||||
│ ├── tamperlog.go # Tamper-evident logging
|
||||
│ └── backup.go # Backup system
|
||||
└── utils/
|
||||
└── hash.go # Hashing utilities (SHA3, etc.)
|
||||
|
||||
Key Benefits
|
||||
|
||||
1. Clear Separation of Concerns: Each package handles a specific responsibility
|
||||
2. Better Testability: Smaller, focused functions are easier to unit test
|
||||
3. Improved Maintainability: Changes to one feature don't affect others
|
||||
4. Go Best Practices: Follows standard Go project layout conventions
|
||||
5. Reduced Coupling: Clear interfaces between components
|
||||
|
||||
Functional Areas Identified
|
||||
|
||||
1. Configuration (~100 lines): Config structs, defaults, loading
|
||||
2. Types (~400 lines): All data structures and constants
|
||||
3. Authentication (~800 lines): User/Group/Token management, JWT, middleware
|
||||
4. Storage (~600 lines): BadgerDB operations, compression, TTL, revisions
|
||||
5. Clustering (~1,200 lines): Gossip, members, sync, Merkle trees
|
||||
6. Server (~600 lines): Server struct, handlers, routes, lifecycle
|
||||
7. Features (~200 lines): Rate limiting, tamper logging, backup
|
||||
8. Utilities (~90 lines): Hashing and other utilities
|
||||
|
||||
Migration Strategy
|
||||
|
||||
1. Start with the most independent modules (types, config, utils)
|
||||
2. Move storage and authentication components
|
||||
3. Extract clustering logic
|
||||
4. Refactor server components last
|
||||
5. Create commits for each major module migration
|
||||
|
||||
The refactoring will maintain zero functional changes - purely cosmetic restructuring for better code organization.
|
@@ -22,8 +22,6 @@ import (
|
||||
"kvs/utils"
|
||||
)
|
||||
|
||||
|
||||
|
||||
// healthHandler returns server health status
|
||||
func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
|
||||
mode := s.getMode()
|
||||
@@ -215,6 +213,104 @@ func (s *Server) deleteKVHandler(w http.ResponseWriter, r *http.Request) {
|
||||
s.logger.WithField("path", path).Info("Value deleted")
|
||||
}
|
||||
|
||||
// getResourceMetadataHandler retrieves metadata for a KV resource
|
||||
func (s *Server) getResourceMetadataHandler(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
path := vars["path"]
|
||||
|
||||
// Get metadata from storage
|
||||
metadata, err := s.authService.GetResourceMetadata(path)
|
||||
if err == badger.ErrKeyNotFound {
|
||||
http.Error(w, "Not Found: No metadata exists for this resource", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.WithError(err).WithField("path", path).Error("Failed to get resource metadata")
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := types.GetResourceMetadataResponse{
|
||||
OwnerUUID: metadata.OwnerUUID,
|
||||
GroupUUID: metadata.GroupUUID,
|
||||
Permissions: metadata.Permissions,
|
||||
TTL: metadata.TTL,
|
||||
CreatedAt: metadata.CreatedAt,
|
||||
UpdatedAt: metadata.UpdatedAt,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// updateResourceMetadataHandler updates metadata for a KV resource
|
||||
func (s *Server) updateResourceMetadataHandler(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
path := vars["path"]
|
||||
|
||||
// Parse request body
|
||||
var req types.UpdateResourceMetadataRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Bad Request: Invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get existing metadata or create new one
|
||||
metadata, err := s.authService.GetResourceMetadata(path)
|
||||
if err == badger.ErrKeyNotFound {
|
||||
// Create new metadata with defaults
|
||||
metadata = &types.ResourceMetadata{
|
||||
OwnerUUID: "",
|
||||
GroupUUID: "",
|
||||
Permissions: types.DefaultPermissions,
|
||||
TTL: "",
|
||||
CreatedAt: time.Now().Unix(),
|
||||
UpdatedAt: time.Now().Unix(),
|
||||
}
|
||||
} else if err != nil {
|
||||
s.logger.WithError(err).WithField("path", path).Error("Failed to get resource metadata")
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Update only provided fields
|
||||
if req.OwnerUUID != nil {
|
||||
metadata.OwnerUUID = *req.OwnerUUID
|
||||
}
|
||||
if req.GroupUUID != nil {
|
||||
metadata.GroupUUID = *req.GroupUUID
|
||||
}
|
||||
if req.Permissions != nil {
|
||||
metadata.Permissions = *req.Permissions
|
||||
}
|
||||
metadata.UpdatedAt = time.Now().Unix()
|
||||
|
||||
// Store updated metadata
|
||||
if err := s.authService.SetResourceMetadata(path, metadata); err != nil {
|
||||
s.logger.WithError(err).WithField("path", path).Error("Failed to update resource metadata")
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := types.GetResourceMetadataResponse{
|
||||
OwnerUUID: metadata.OwnerUUID,
|
||||
GroupUUID: metadata.GroupUUID,
|
||||
Permissions: metadata.Permissions,
|
||||
TTL: metadata.TTL,
|
||||
CreatedAt: metadata.CreatedAt,
|
||||
UpdatedAt: metadata.UpdatedAt,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
|
||||
s.logger.WithFields(logrus.Fields{
|
||||
"path": path,
|
||||
"owner_uuid": metadata.OwnerUUID,
|
||||
"group_uuid": metadata.GroupUUID,
|
||||
}).Info("Resource metadata updated")
|
||||
}
|
||||
|
||||
// isClusterMember checks if request is from a cluster member
|
||||
func (s *Server) isClusterMember(remoteAddr string) bool {
|
||||
host, _, err := net.SplitHostPort(remoteAddr)
|
||||
@@ -1271,3 +1367,29 @@ func (s *Server) getRevisionHistory(key string) ([]map[string]interface{}, error
|
||||
func (s *Server) getSpecificRevision(key string, revision int) (*types.StoredValue, error) {
|
||||
return s.revisionService.GetSpecificRevision(key, revision)
|
||||
}
|
||||
|
||||
// clusterBootstrapHandler provides the cluster secret to authenticated administrators (Issue #13)
|
||||
func (s *Server) clusterBootstrapHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Ensure clustering is enabled
|
||||
if !s.config.ClusteringEnabled {
|
||||
http.Error(w, "Clustering is disabled", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure cluster secret is configured
|
||||
if s.config.ClusterSecret == "" {
|
||||
s.logger.Error("Cluster secret is not configured")
|
||||
http.Error(w, "Cluster secret is not configured", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return the cluster secret for secure bootstrap
|
||||
response := map[string]string{
|
||||
"cluster_secret": s.config.ClusterSecret,
|
||||
}
|
||||
|
||||
s.logger.WithField("remote_addr", r.RemoteAddr).Info("Cluster secret retrieved for bootstrap")
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
152
server/routes.go
152
server/routes.go
@@ -1,6 +1,8 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
@@ -8,46 +10,134 @@ import (
|
||||
func (s *Server) setupRoutes() *mux.Router {
|
||||
router := mux.NewRouter()
|
||||
|
||||
// Health endpoint
|
||||
// Health endpoint (always available)
|
||||
router.HandleFunc("/health", s.healthHandler).Methods("GET")
|
||||
|
||||
// KV endpoints
|
||||
router.HandleFunc("/kv/{path:.+}", s.getKVHandler).Methods("GET")
|
||||
router.HandleFunc("/kv/{path:.+}", s.putKVHandler).Methods("PUT")
|
||||
router.HandleFunc("/kv/{path:.+}", s.deleteKVHandler).Methods("DELETE")
|
||||
// Resource Metadata Management endpoints (Issue #12) - Must come BEFORE general KV routes
|
||||
// These need to be registered first to prevent /kv/{path:.+} from matching metadata paths
|
||||
if s.config.AuthEnabled {
|
||||
router.Handle("/kv/{path:.+}/metadata", s.authService.Middleware(
|
||||
[]string{"admin:users:read"}, nil, "",
|
||||
)(s.getResourceMetadataHandler)).Methods("GET")
|
||||
|
||||
// Member endpoints
|
||||
router.HandleFunc("/members/", s.getMembersHandler).Methods("GET")
|
||||
router.HandleFunc("/members/join", s.joinMemberHandler).Methods("POST")
|
||||
router.HandleFunc("/members/leave", s.leaveMemberHandler).Methods("DELETE")
|
||||
router.HandleFunc("/members/gossip", s.gossipHandler).Methods("POST")
|
||||
router.HandleFunc("/members/pairs_by_time", s.pairsByTimeHandler).Methods("POST") // Still available for clients
|
||||
router.Handle("/kv/{path:.+}/metadata", s.authService.Middleware(
|
||||
[]string{"admin:users:update"}, nil, "",
|
||||
)(s.updateResourceMetadataHandler)).Methods("PUT")
|
||||
}
|
||||
|
||||
// Merkle Tree endpoints
|
||||
router.HandleFunc("/merkle_tree/root", s.getMerkleRootHandler).Methods("GET")
|
||||
router.HandleFunc("/merkle_tree/diff", s.getMerkleDiffHandler).Methods("POST")
|
||||
router.HandleFunc("/kv_range", s.getKVRangeHandler).Methods("POST") // New endpoint for fetching ranges
|
||||
// KV endpoints (with conditional authentication based on anonymous access settings)
|
||||
// GET endpoint - require auth if anonymous read is disabled
|
||||
if s.config.AuthEnabled && !s.config.AllowAnonymousRead {
|
||||
router.Handle("/kv/{path:.+}", s.authService.Middleware(
|
||||
[]string{"read"}, nil, "",
|
||||
)(s.getKVHandler)).Methods("GET")
|
||||
} else {
|
||||
router.HandleFunc("/kv/{path:.+}", s.getKVHandler).Methods("GET")
|
||||
}
|
||||
|
||||
// User Management endpoints
|
||||
router.HandleFunc("/api/users", s.createUserHandler).Methods("POST")
|
||||
router.HandleFunc("/api/users/{uuid}", s.getUserHandler).Methods("GET")
|
||||
router.HandleFunc("/api/users/{uuid}", s.updateUserHandler).Methods("PUT")
|
||||
router.HandleFunc("/api/users/{uuid}", s.deleteUserHandler).Methods("DELETE")
|
||||
// PUT endpoint - require auth if anonymous write is disabled
|
||||
if s.config.AuthEnabled && !s.config.AllowAnonymousWrite {
|
||||
router.Handle("/kv/{path:.+}", s.authService.Middleware(
|
||||
[]string{"write"}, nil, "",
|
||||
)(s.putKVHandler)).Methods("PUT")
|
||||
} else {
|
||||
router.HandleFunc("/kv/{path:.+}", s.putKVHandler).Methods("PUT")
|
||||
}
|
||||
|
||||
// Group Management endpoints
|
||||
router.HandleFunc("/api/groups", s.createGroupHandler).Methods("POST")
|
||||
router.HandleFunc("/api/groups/{uuid}", s.getGroupHandler).Methods("GET")
|
||||
router.HandleFunc("/api/groups/{uuid}", s.updateGroupHandler).Methods("PUT")
|
||||
router.HandleFunc("/api/groups/{uuid}", s.deleteGroupHandler).Methods("DELETE")
|
||||
// DELETE endpoint - always require authentication (no anonymous delete)
|
||||
if s.config.AuthEnabled {
|
||||
router.Handle("/kv/{path:.+}", s.authService.Middleware(
|
||||
[]string{"delete"}, nil, "",
|
||||
)(s.deleteKVHandler)).Methods("DELETE")
|
||||
} else {
|
||||
router.HandleFunc("/kv/{path:.+}", s.deleteKVHandler).Methods("DELETE")
|
||||
}
|
||||
|
||||
// Token Management endpoints
|
||||
router.HandleFunc("/api/tokens", s.createTokenHandler).Methods("POST")
|
||||
// Member endpoints (available when clustering is enabled)
|
||||
if s.config.ClusteringEnabled {
|
||||
// GET /members/ is unprotected for monitoring/inspection
|
||||
router.HandleFunc("/members/", s.getMembersHandler).Methods("GET")
|
||||
|
||||
// Revision History endpoints
|
||||
router.HandleFunc("/api/data/{key}/history", s.getRevisionHistoryHandler).Methods("GET")
|
||||
router.HandleFunc("/api/data/{key}/history/{revision}", s.getSpecificRevisionHandler).Methods("GET")
|
||||
// Apply cluster authentication middleware to all cluster communication endpoints
|
||||
if s.clusterAuthService != nil {
|
||||
router.Handle("/members/join", s.clusterAuthService.Middleware(http.HandlerFunc(s.joinMemberHandler))).Methods("POST")
|
||||
router.Handle("/members/leave", s.clusterAuthService.Middleware(http.HandlerFunc(s.leaveMemberHandler))).Methods("DELETE")
|
||||
router.Handle("/members/gossip", s.clusterAuthService.Middleware(http.HandlerFunc(s.gossipHandler))).Methods("POST")
|
||||
router.Handle("/members/pairs_by_time", s.clusterAuthService.Middleware(http.HandlerFunc(s.pairsByTimeHandler))).Methods("POST")
|
||||
|
||||
// Backup Status endpoint
|
||||
// Merkle Tree endpoints (clustering feature)
|
||||
router.Handle("/merkle_tree/root", s.clusterAuthService.Middleware(http.HandlerFunc(s.getMerkleRootHandler))).Methods("GET")
|
||||
router.Handle("/merkle_tree/diff", s.clusterAuthService.Middleware(http.HandlerFunc(s.getMerkleDiffHandler))).Methods("POST")
|
||||
router.Handle("/kv_range", s.clusterAuthService.Middleware(http.HandlerFunc(s.getKVRangeHandler))).Methods("POST")
|
||||
} else {
|
||||
// Fallback to unprotected endpoints (for backwards compatibility)
|
||||
router.HandleFunc("/members/join", s.joinMemberHandler).Methods("POST")
|
||||
router.HandleFunc("/members/leave", s.leaveMemberHandler).Methods("DELETE")
|
||||
router.HandleFunc("/members/gossip", s.gossipHandler).Methods("POST")
|
||||
router.HandleFunc("/members/pairs_by_time", s.pairsByTimeHandler).Methods("POST")
|
||||
|
||||
// Merkle Tree endpoints (clustering feature)
|
||||
router.HandleFunc("/merkle_tree/root", s.getMerkleRootHandler).Methods("GET")
|
||||
router.HandleFunc("/merkle_tree/diff", s.getMerkleDiffHandler).Methods("POST")
|
||||
router.HandleFunc("/kv_range", s.getKVRangeHandler).Methods("POST")
|
||||
}
|
||||
}
|
||||
|
||||
// Authentication and user management endpoints (available when auth is enabled)
|
||||
if s.config.AuthEnabled {
|
||||
// User Management endpoints (with authentication middleware)
|
||||
router.Handle("/api/users", s.authService.Middleware(
|
||||
[]string{"admin:users:create"}, nil, "",
|
||||
)(s.createUserHandler)).Methods("POST")
|
||||
|
||||
router.Handle("/api/users/{uuid}", s.authService.Middleware(
|
||||
[]string{"admin:users:read"}, nil, "",
|
||||
)(s.getUserHandler)).Methods("GET")
|
||||
|
||||
router.Handle("/api/users/{uuid}", s.authService.Middleware(
|
||||
[]string{"admin:users:update"}, nil, "",
|
||||
)(s.updateUserHandler)).Methods("PUT")
|
||||
|
||||
router.Handle("/api/users/{uuid}", s.authService.Middleware(
|
||||
[]string{"admin:users:delete"}, nil, "",
|
||||
)(s.deleteUserHandler)).Methods("DELETE")
|
||||
|
||||
// Group Management endpoints (with authentication middleware)
|
||||
router.Handle("/api/groups", s.authService.Middleware(
|
||||
[]string{"admin:groups:create"}, nil, "",
|
||||
)(s.createGroupHandler)).Methods("POST")
|
||||
|
||||
router.Handle("/api/groups/{uuid}", s.authService.Middleware(
|
||||
[]string{"admin:groups:read"}, nil, "",
|
||||
)(s.getGroupHandler)).Methods("GET")
|
||||
|
||||
router.Handle("/api/groups/{uuid}", s.authService.Middleware(
|
||||
[]string{"admin:groups:update"}, nil, "",
|
||||
)(s.updateGroupHandler)).Methods("PUT")
|
||||
|
||||
router.Handle("/api/groups/{uuid}", s.authService.Middleware(
|
||||
[]string{"admin:groups:delete"}, nil, "",
|
||||
)(s.deleteGroupHandler)).Methods("DELETE")
|
||||
|
||||
// Token Management endpoints (with authentication middleware)
|
||||
router.Handle("/api/tokens", s.authService.Middleware(
|
||||
[]string{"admin:tokens:create"}, nil, "",
|
||||
)(s.createTokenHandler)).Methods("POST")
|
||||
|
||||
// Cluster Bootstrap endpoint (Issue #13) - Protected by JWT authentication
|
||||
// Allows authenticated administrators to retrieve the cluster secret for new nodes
|
||||
router.Handle("/auth/cluster-bootstrap", s.authService.Middleware(
|
||||
[]string{"admin:tokens:create"}, nil, "",
|
||||
)(s.clusterBootstrapHandler)).Methods("GET")
|
||||
}
|
||||
|
||||
// Revision History endpoints (available when revision history is enabled)
|
||||
if s.config.RevisionHistoryEnabled {
|
||||
router.HandleFunc("/api/data/{key}/history", s.getRevisionHistoryHandler).Methods("GET")
|
||||
router.HandleFunc("/api/data/{key}/history/{revision}", s.getSpecificRevisionHandler).Methods("GET")
|
||||
}
|
||||
|
||||
// Backup Status endpoint (always available if backup is enabled)
|
||||
router.HandleFunc("/api/backup/status", s.getBackupStatusHandler).Methods("GET")
|
||||
|
||||
return router
|
||||
|
155
server/server.go
155
server/server.go
@@ -2,10 +2,12 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -17,6 +19,7 @@ import (
|
||||
"kvs/cluster"
|
||||
"kvs/storage"
|
||||
"kvs/types"
|
||||
"kvs/utils"
|
||||
)
|
||||
|
||||
// Server represents the KVS node
|
||||
@@ -47,7 +50,8 @@ type Server struct {
|
||||
backupMu sync.RWMutex // Protects backup status
|
||||
|
||||
// Authentication service
|
||||
authService *auth.AuthService
|
||||
authService *auth.AuthService
|
||||
clusterAuthService *auth.ClusterAuthService
|
||||
}
|
||||
|
||||
// NewServer initializes and returns a new Server instance
|
||||
@@ -115,7 +119,19 @@ func NewServer(config *types.Config) (*Server, error) {
|
||||
server.revisionService = storage.NewRevisionService(storageService)
|
||||
|
||||
// Initialize authentication service
|
||||
server.authService = auth.NewAuthService(db, logger)
|
||||
server.authService = auth.NewAuthService(db, logger, config)
|
||||
|
||||
// Initialize cluster authentication service (Issue #13)
|
||||
if config.ClusteringEnabled {
|
||||
server.clusterAuthService = auth.NewClusterAuthService(config.ClusterSecret, logger)
|
||||
}
|
||||
|
||||
// Setup initial root account if needed (Issue #3)
|
||||
if config.AuthEnabled {
|
||||
if err := server.setupRootAccount(); err != nil {
|
||||
return nil, fmt.Errorf("failed to setup root account: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Merkle tree using cluster service
|
||||
if err := server.syncService.InitializeMerkleTree(); err != nil {
|
||||
@@ -182,3 +198,138 @@ func (s *Server) getBackupStatus() types.BackupStatus {
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
// setupRootAccount creates an initial root account if no users exist and no seed nodes are configured
|
||||
func (s *Server) setupRootAccount() error {
|
||||
// Only create root account if:
|
||||
// 1. No users exist in the database
|
||||
// 2. No seed nodes are configured (standalone mode)
|
||||
hasUsers, err := s.authService.HasUsers()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if users exist: %v", err)
|
||||
}
|
||||
|
||||
// If users already exist or we have seed nodes, no need to create root account
|
||||
if hasUsers || len(s.config.SeedNodes) > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
s.logger.Info("Creating initial root account for empty database with no seed nodes")
|
||||
|
||||
// Import required packages for user creation
|
||||
// Note: We need these imports at the top of the file
|
||||
return s.createRootUserAndToken()
|
||||
}
|
||||
|
||||
// createRootUserAndToken creates the root user, admin group, and initial token
|
||||
func (s *Server) createRootUserAndToken() error {
|
||||
rootNickname := "root"
|
||||
adminGroupName := "admin"
|
||||
|
||||
// Generate UUIDs
|
||||
rootUserUUID := "root-" + time.Now().Format("20060102-150405")
|
||||
adminGroupUUID := "admin-" + time.Now().Format("20060102-150405")
|
||||
now := time.Now().Unix()
|
||||
|
||||
// Create admin group
|
||||
adminGroup := types.Group{
|
||||
UUID: adminGroupUUID,
|
||||
NameHash: hashGroupName(adminGroupName),
|
||||
Members: []string{rootUserUUID},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
// Create root user
|
||||
rootUser := types.User{
|
||||
UUID: rootUserUUID,
|
||||
NicknameHash: hashUserNickname(rootNickname),
|
||||
Groups: []string{adminGroupUUID},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
// Store group and user in database
|
||||
if err := s.storeUserAndGroup(&rootUser, &adminGroup); err != nil {
|
||||
return fmt.Errorf("failed to store root user and admin group: %v", err)
|
||||
}
|
||||
|
||||
// Create API token with full administrative scopes
|
||||
adminScopes := []string{
|
||||
"admin:users:create", "admin:users:read", "admin:users:update", "admin:users:delete",
|
||||
"admin:groups:create", "admin:groups:read", "admin:groups:update", "admin:groups:delete",
|
||||
"admin:tokens:create", "admin:tokens:revoke",
|
||||
"read", "write", "delete",
|
||||
}
|
||||
|
||||
// Generate token with 24 hour expiration for initial setup
|
||||
tokenString, expiresAt, err := auth.GenerateJWT(rootUserUUID, adminScopes, 24)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate root token: %v", err)
|
||||
}
|
||||
|
||||
// Store token in database
|
||||
if err := s.storeAPIToken(tokenString, rootUserUUID, adminScopes, expiresAt); err != nil {
|
||||
return fmt.Errorf("failed to store root token: %v", err)
|
||||
}
|
||||
|
||||
// Log the token securely (one-time display)
|
||||
s.logger.WithFields(logrus.Fields{
|
||||
"user_uuid": rootUserUUID,
|
||||
"group_uuid": adminGroupUUID,
|
||||
"expires_at": time.Unix(expiresAt, 0).Format(time.RFC3339),
|
||||
"expires_in": "24 hours",
|
||||
}).Warn("Root account created - SAVE THIS TOKEN:")
|
||||
|
||||
// Display token prominently
|
||||
fmt.Printf("\n" + strings.Repeat("=", 80) + "\n")
|
||||
fmt.Printf("🔐 ROOT ACCOUNT CREATED - INITIAL SETUP TOKEN\n")
|
||||
fmt.Printf("===========================================\n")
|
||||
fmt.Printf("User UUID: %s\n", rootUserUUID)
|
||||
fmt.Printf("Group UUID: %s\n", adminGroupUUID)
|
||||
fmt.Printf("Token: %s\n", tokenString)
|
||||
fmt.Printf("Expires: %s (24 hours)\n", time.Unix(expiresAt, 0).Format(time.RFC3339))
|
||||
fmt.Printf("\n⚠️ IMPORTANT: Save this token immediately!\n")
|
||||
fmt.Printf(" This is the only time it will be displayed.\n")
|
||||
fmt.Printf(" Use this token to authenticate and create additional users.\n")
|
||||
fmt.Printf(strings.Repeat("=", 80) + "\n\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// hashUserNickname creates a hash of the user nickname (similar to handlers.go)
|
||||
func hashUserNickname(nickname string) string {
|
||||
return utils.HashSHA3512(nickname)
|
||||
}
|
||||
|
||||
// hashGroupName creates a hash of the group name (similar to handlers.go)
|
||||
func hashGroupName(groupname string) string {
|
||||
return utils.HashSHA3512(groupname)
|
||||
}
|
||||
|
||||
// storeUserAndGroup stores both user and group in the database
|
||||
func (s *Server) storeUserAndGroup(user *types.User, group *types.Group) error {
|
||||
return s.db.Update(func(txn *badger.Txn) error {
|
||||
// Store user
|
||||
userData, err := json.Marshal(user)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal user data: %v", err)
|
||||
}
|
||||
|
||||
if err := txn.Set([]byte(auth.UserStorageKey(user.UUID)), userData); err != nil {
|
||||
return fmt.Errorf("failed to store user: %v", err)
|
||||
}
|
||||
|
||||
// Store group
|
||||
groupData, err := json.Marshal(group)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal group data: %v", err)
|
||||
}
|
||||
|
||||
if err := txn.Set([]byte(auth.GroupStorageKey(group.UUID)), groupData); err != nil {
|
||||
return fmt.Errorf("failed to store group: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
@@ -2,7 +2,7 @@ package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
)
|
||||
|
||||
@@ -57,4 +57,4 @@ func (c *CompressionService) DecompressData(compressedData []byte) ([]byte, erro
|
||||
return nil, fmt.Errorf("decompressor not initialized")
|
||||
}
|
||||
return c.decompressor.DecodeAll(compressedData, nil)
|
||||
}
|
||||
}
|
||||
|
@@ -34,10 +34,10 @@ func GetRevisionKey(baseKey string, revision int) string {
|
||||
func (r *RevisionService) StoreRevisionHistory(txn *badger.Txn, key string, storedValue types.StoredValue, ttl time.Duration) error {
|
||||
// Get existing metadata to check current revisions
|
||||
metadataKey := auth.ResourceMetadataKey(key)
|
||||
|
||||
|
||||
var metadata types.ResourceMetadata
|
||||
var currentRevisions []int
|
||||
|
||||
|
||||
// Try to get existing metadata
|
||||
metadataData, err := r.storage.RetrieveWithDecompression(txn, []byte(metadataKey))
|
||||
if err == badger.ErrKeyNotFound {
|
||||
@@ -60,7 +60,7 @@ func (r *RevisionService) StoreRevisionHistory(txn *badger.Txn, key string, stor
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal metadata: %v", err)
|
||||
}
|
||||
|
||||
|
||||
// Extract current revisions (we store them as a custom field)
|
||||
if metadata.TTL == "" {
|
||||
currentRevisions = []int{}
|
||||
@@ -69,13 +69,13 @@ func (r *RevisionService) StoreRevisionHistory(txn *badger.Txn, key string, stor
|
||||
currentRevisions = []int{1, 2, 3} // Assume all revisions exist for existing keys
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Revision rotation logic: shift existing revisions
|
||||
if len(currentRevisions) >= 3 {
|
||||
// Delete oldest revision (rev:3)
|
||||
oldestRevKey := GetRevisionKey(key, 3)
|
||||
txn.Delete([]byte(oldestRevKey))
|
||||
|
||||
|
||||
// Shift rev:2 → rev:3
|
||||
rev2Key := GetRevisionKey(key, 2)
|
||||
rev2Data, err := r.storage.RetrieveWithDecompression(txn, []byte(rev2Key))
|
||||
@@ -83,8 +83,8 @@ func (r *RevisionService) StoreRevisionHistory(txn *badger.Txn, key string, stor
|
||||
rev3Key := GetRevisionKey(key, 3)
|
||||
r.storage.StoreWithTTL(txn, []byte(rev3Key), rev2Data, ttl)
|
||||
}
|
||||
|
||||
// Shift rev:1 → rev:2
|
||||
|
||||
// Shift rev:1 → rev:2
|
||||
rev1Key := GetRevisionKey(key, 1)
|
||||
rev1Data, err := r.storage.RetrieveWithDecompression(txn, []byte(rev1Key))
|
||||
if err == nil {
|
||||
@@ -92,80 +92,80 @@ func (r *RevisionService) StoreRevisionHistory(txn *badger.Txn, key string, stor
|
||||
r.storage.StoreWithTTL(txn, []byte(rev2Key), rev1Data, ttl)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Store current value as rev:1
|
||||
currentValueBytes, err := json.Marshal(storedValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal current value for revision: %v", err)
|
||||
}
|
||||
|
||||
|
||||
rev1Key := GetRevisionKey(key, 1)
|
||||
err = r.storage.StoreWithTTL(txn, []byte(rev1Key), currentValueBytes, ttl)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to store revision 1: %v", err)
|
||||
}
|
||||
|
||||
|
||||
// Update metadata with new revision count
|
||||
metadata.UpdatedAt = time.Now().Unix()
|
||||
metadataBytes, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal metadata: %v", err)
|
||||
}
|
||||
|
||||
|
||||
return r.storage.StoreWithTTL(txn, []byte(metadataKey), metadataBytes, ttl)
|
||||
}
|
||||
|
||||
// GetRevisionHistory retrieves all available revisions for a given key
|
||||
func (r *RevisionService) GetRevisionHistory(key string) ([]map[string]interface{}, error) {
|
||||
var revisions []map[string]interface{}
|
||||
|
||||
|
||||
err := r.storage.db.View(func(txn *badger.Txn) error {
|
||||
// Check revisions 1, 2, 3
|
||||
for rev := 1; rev <= 3; rev++ {
|
||||
revKey := GetRevisionKey(key, rev)
|
||||
|
||||
|
||||
revData, err := r.storage.RetrieveWithDecompression(txn, []byte(revKey))
|
||||
if err == badger.ErrKeyNotFound {
|
||||
continue // Skip missing revisions
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("failed to retrieve revision %d: %v", rev, err)
|
||||
}
|
||||
|
||||
|
||||
var storedValue types.StoredValue
|
||||
err = json.Unmarshal(revData, &storedValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal revision %d: %v", rev, err)
|
||||
}
|
||||
|
||||
|
||||
var data interface{}
|
||||
err = json.Unmarshal(storedValue.Data, &data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal revision %d data: %v", rev, err)
|
||||
}
|
||||
|
||||
|
||||
revision := map[string]interface{}{
|
||||
"revision": rev,
|
||||
"uuid": storedValue.UUID,
|
||||
"timestamp": storedValue.Timestamp,
|
||||
"data": data,
|
||||
}
|
||||
|
||||
|
||||
revisions = append(revisions, revision)
|
||||
}
|
||||
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
// Sort revisions by revision number (newest first)
|
||||
// Note: they're already in order since we iterate 1->3, but reverse for newest first
|
||||
for i, j := 0, len(revisions)-1; i < j; i, j = i+1, j-1 {
|
||||
revisions[i], revisions[j] = revisions[j], revisions[i]
|
||||
}
|
||||
|
||||
|
||||
return revisions, nil
|
||||
}
|
||||
|
||||
@@ -174,23 +174,23 @@ func (r *RevisionService) GetSpecificRevision(key string, revision int) (*types.
|
||||
if revision < 1 || revision > 3 {
|
||||
return nil, fmt.Errorf("invalid revision number: %d (must be 1-3)", revision)
|
||||
}
|
||||
|
||||
|
||||
var storedValue types.StoredValue
|
||||
err := r.storage.db.View(func(txn *badger.Txn) error {
|
||||
revKey := GetRevisionKey(key, revision)
|
||||
|
||||
|
||||
revData, err := r.storage.RetrieveWithDecompression(txn, []byte(revKey))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
return json.Unmarshal(revData, &storedValue)
|
||||
})
|
||||
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
return &storedValue, nil
|
||||
}
|
||||
|
||||
@@ -200,15 +200,15 @@ func GetRevisionFromPath(path string) (string, int, error) {
|
||||
if len(parts) < 4 || parts[len(parts)-2] != "rev" {
|
||||
return "", 0, fmt.Errorf("invalid revision path format")
|
||||
}
|
||||
|
||||
|
||||
revisionStr := parts[len(parts)-1]
|
||||
revision, err := strconv.Atoi(revisionStr)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("invalid revision number: %s", revisionStr)
|
||||
}
|
||||
|
||||
|
||||
// Reconstruct the base key without the "/rev/N" suffix
|
||||
baseKey := strings.Join(parts[:len(parts)-2], "/")
|
||||
|
||||
|
||||
return baseKey, revision, nil
|
||||
}
|
||||
}
|
||||
|
@@ -12,17 +12,17 @@ import (
|
||||
|
||||
// StorageService handles all BadgerDB operations and data management
|
||||
type StorageService struct {
|
||||
db *badger.DB
|
||||
config *types.Config
|
||||
compressionSvc *CompressionService
|
||||
logger *logrus.Logger
|
||||
db *badger.DB
|
||||
config *types.Config
|
||||
compressionSvc *CompressionService
|
||||
logger *logrus.Logger
|
||||
}
|
||||
|
||||
// NewStorageService creates a new storage service
|
||||
func NewStorageService(db *badger.DB, config *types.Config, logger *logrus.Logger) (*StorageService, error) {
|
||||
var compressionSvc *CompressionService
|
||||
var err error
|
||||
|
||||
|
||||
// Initialize compression if enabled
|
||||
if config.CompressionEnabled {
|
||||
compressionSvc, err = NewCompressionService()
|
||||
@@ -50,7 +50,7 @@ func (s *StorageService) Close() {
|
||||
func (s *StorageService) StoreWithTTL(txn *badger.Txn, key []byte, data []byte, ttl time.Duration) error {
|
||||
var finalData []byte
|
||||
var err error
|
||||
|
||||
|
||||
// Compress data if compression is enabled
|
||||
if s.config.CompressionEnabled && s.compressionSvc != nil {
|
||||
finalData, err = s.compressionSvc.CompressData(data)
|
||||
@@ -60,14 +60,14 @@ func (s *StorageService) StoreWithTTL(txn *badger.Txn, key []byte, data []byte,
|
||||
} else {
|
||||
finalData = data
|
||||
}
|
||||
|
||||
|
||||
entry := badger.NewEntry(key, finalData)
|
||||
|
||||
|
||||
// Apply TTL if specified
|
||||
if ttl > 0 {
|
||||
entry = entry.WithTTL(ttl)
|
||||
}
|
||||
|
||||
|
||||
return txn.SetEntry(entry)
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ func (s *StorageService) RetrieveWithDecompression(txn *badger.Txn, key []byte)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
var compressedData []byte
|
||||
err = item.Value(func(val []byte) error {
|
||||
compressedData = append(compressedData, val...)
|
||||
@@ -86,12 +86,12 @@ func (s *StorageService) RetrieveWithDecompression(txn *badger.Txn, key []byte)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
// Decompress data if compression is enabled
|
||||
if s.config.CompressionEnabled && s.compressionSvc != nil {
|
||||
return s.compressionSvc.DecompressData(compressedData)
|
||||
}
|
||||
|
||||
|
||||
return compressedData, nil
|
||||
}
|
||||
|
||||
@@ -109,4 +109,4 @@ func (s *StorageService) DecompressData(compressedData []byte) ([]byte, error) {
|
||||
return compressedData, nil
|
||||
}
|
||||
return s.compressionSvc.DecompressData(compressedData)
|
||||
}
|
||||
}
|
||||
|
117
types/types.go
117
types/types.go
@@ -13,20 +13,20 @@ type StoredValue struct {
|
||||
|
||||
// User represents a system user
|
||||
type User struct {
|
||||
UUID string `json:"uuid"` // Server-generated UUID
|
||||
UUID string `json:"uuid"` // Server-generated UUID
|
||||
NicknameHash string `json:"nickname_hash"` // SHA3-512 hash of nickname
|
||||
Groups []string `json:"groups"` // List of group UUIDs this user belongs to
|
||||
CreatedAt int64 `json:"created_at"` // Unix timestamp
|
||||
UpdatedAt int64 `json:"updated_at"` // Unix timestamp
|
||||
Groups []string `json:"groups"` // List of group UUIDs this user belongs to
|
||||
CreatedAt int64 `json:"created_at"` // Unix timestamp
|
||||
UpdatedAt int64 `json:"updated_at"` // Unix timestamp
|
||||
}
|
||||
|
||||
// Group represents a user group
|
||||
type Group struct {
|
||||
UUID string `json:"uuid"` // Server-generated UUID
|
||||
NameHash string `json:"name_hash"` // SHA3-512 hash of group name
|
||||
Members []string `json:"members"` // List of user UUIDs in this group
|
||||
CreatedAt int64 `json:"created_at"` // Unix timestamp
|
||||
UpdatedAt int64 `json:"updated_at"` // Unix timestamp
|
||||
UUID string `json:"uuid"` // Server-generated UUID
|
||||
NameHash string `json:"name_hash"` // SHA3-512 hash of group name
|
||||
Members []string `json:"members"` // List of user UUIDs in this group
|
||||
CreatedAt int64 `json:"created_at"` // Unix timestamp
|
||||
UpdatedAt int64 `json:"updated_at"` // Unix timestamp
|
||||
}
|
||||
|
||||
// APIToken represents a JWT authentication token
|
||||
@@ -40,12 +40,12 @@ type APIToken struct {
|
||||
|
||||
// ResourceMetadata contains ownership and permission information for stored resources
|
||||
type ResourceMetadata struct {
|
||||
OwnerUUID string `json:"owner_uuid"` // UUID of the resource owner
|
||||
GroupUUID string `json:"group_uuid"` // UUID of the resource group
|
||||
Permissions int `json:"permissions"` // 12-bit permission mask (POSIX-inspired)
|
||||
TTL string `json:"ttl"` // Time-to-live duration (Go format)
|
||||
CreatedAt int64 `json:"created_at"` // Unix timestamp when resource was created
|
||||
UpdatedAt int64 `json:"updated_at"` // Unix timestamp when resource was last updated
|
||||
OwnerUUID string `json:"owner_uuid"` // UUID of the resource owner
|
||||
GroupUUID string `json:"group_uuid"` // UUID of the resource group
|
||||
Permissions int `json:"permissions"` // 12-bit permission mask (POSIX-inspired)
|
||||
TTL string `json:"ttl"` // Time-to-live duration (Go format)
|
||||
CreatedAt int64 `json:"created_at"` // Unix timestamp when resource was created
|
||||
UpdatedAt int64 `json:"updated_at"` // Unix timestamp when resource was last updated
|
||||
}
|
||||
|
||||
// Permission constants for POSIX-inspired ACL
|
||||
@@ -55,19 +55,19 @@ const (
|
||||
PermOwnerDelete = 1 << 10
|
||||
PermOwnerWrite = 1 << 9
|
||||
PermOwnerRead = 1 << 8
|
||||
|
||||
|
||||
// Group permissions (bits 7-4)
|
||||
PermGroupCreate = 1 << 7
|
||||
PermGroupDelete = 1 << 6
|
||||
PermGroupWrite = 1 << 5
|
||||
PermGroupRead = 1 << 4
|
||||
|
||||
|
||||
// Others permissions (bits 3-0)
|
||||
PermOthersCreate = 1 << 3
|
||||
PermOthersDelete = 1 << 2
|
||||
PermOthersWrite = 1 << 1
|
||||
PermOthersRead = 1 << 0
|
||||
|
||||
|
||||
// Default permissions: Owner(1111), Group(0110), Others(0010)
|
||||
DefaultPermissions = (PermOwnerCreate | PermOwnerDelete | PermOwnerWrite | PermOwnerRead) |
|
||||
(PermGroupWrite | PermGroupRead) |
|
||||
@@ -131,6 +131,22 @@ type CreateTokenResponse struct {
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
|
||||
// Resource Metadata Management API structures (Issue #12)
|
||||
type GetResourceMetadataResponse struct {
|
||||
OwnerUUID string `json:"owner_uuid"`
|
||||
GroupUUID string `json:"group_uuid"`
|
||||
Permissions int `json:"permissions"`
|
||||
TTL string `json:"ttl"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type UpdateResourceMetadataRequest struct {
|
||||
OwnerUUID *string `json:"owner_uuid,omitempty"`
|
||||
GroupUUID *string `json:"group_uuid,omitempty"`
|
||||
Permissions *int `json:"permissions,omitempty"`
|
||||
}
|
||||
|
||||
// Cluster and member management types
|
||||
type Member struct {
|
||||
ID string `json:"id"`
|
||||
@@ -231,46 +247,57 @@ type KVRangeResponse struct {
|
||||
|
||||
// Configuration
|
||||
type Config struct {
|
||||
NodeID string `yaml:"node_id"`
|
||||
BindAddress string `yaml:"bind_address"`
|
||||
Port int `yaml:"port"`
|
||||
DataDir string `yaml:"data_dir"`
|
||||
SeedNodes []string `yaml:"seed_nodes"`
|
||||
ReadOnly bool `yaml:"read_only"`
|
||||
LogLevel string `yaml:"log_level"`
|
||||
GossipIntervalMin int `yaml:"gossip_interval_min"`
|
||||
GossipIntervalMax int `yaml:"gossip_interval_max"`
|
||||
SyncInterval int `yaml:"sync_interval"`
|
||||
CatchupInterval int `yaml:"catchup_interval"`
|
||||
BootstrapMaxAgeHours int `yaml:"bootstrap_max_age_hours"`
|
||||
ThrottleDelayMs int `yaml:"throttle_delay_ms"`
|
||||
FetchDelayMs int `yaml:"fetch_delay_ms"`
|
||||
|
||||
NodeID string `yaml:"node_id"`
|
||||
BindAddress string `yaml:"bind_address"`
|
||||
Port int `yaml:"port"`
|
||||
DataDir string `yaml:"data_dir"`
|
||||
SeedNodes []string `yaml:"seed_nodes"`
|
||||
ReadOnly bool `yaml:"read_only"`
|
||||
LogLevel string `yaml:"log_level"`
|
||||
GossipIntervalMin int `yaml:"gossip_interval_min"`
|
||||
GossipIntervalMax int `yaml:"gossip_interval_max"`
|
||||
SyncInterval int `yaml:"sync_interval"`
|
||||
CatchupInterval int `yaml:"catchup_interval"`
|
||||
BootstrapMaxAgeHours int `yaml:"bootstrap_max_age_hours"`
|
||||
ThrottleDelayMs int `yaml:"throttle_delay_ms"`
|
||||
FetchDelayMs int `yaml:"fetch_delay_ms"`
|
||||
|
||||
// Database compression configuration
|
||||
CompressionEnabled bool `yaml:"compression_enabled"`
|
||||
CompressionLevel int `yaml:"compression_level"`
|
||||
|
||||
|
||||
// TTL configuration
|
||||
DefaultTTL string `yaml:"default_ttl"` // Go duration format, "0" means no default TTL
|
||||
MaxJSONSize int `yaml:"max_json_size"` // Maximum JSON size in bytes
|
||||
|
||||
DefaultTTL string `yaml:"default_ttl"` // Go duration format, "0" means no default TTL
|
||||
MaxJSONSize int `yaml:"max_json_size"` // Maximum JSON size in bytes
|
||||
|
||||
// Rate limiting configuration
|
||||
RateLimitRequests int `yaml:"rate_limit_requests"` // Max requests per window
|
||||
RateLimitWindow string `yaml:"rate_limit_window"` // Window duration (Go format)
|
||||
|
||||
|
||||
// Tamper-evident logging configuration
|
||||
TamperLogActions []string `yaml:"tamper_log_actions"` // Actions to log
|
||||
|
||||
|
||||
// Backup system configuration
|
||||
BackupEnabled bool `yaml:"backup_enabled"` // Enable/disable automated backups
|
||||
BackupSchedule string `yaml:"backup_schedule"` // Cron schedule format
|
||||
BackupPath string `yaml:"backup_path"` // Directory to store backups
|
||||
BackupRetention int `yaml:"backup_retention"` // Days to keep backups
|
||||
|
||||
BackupEnabled bool `yaml:"backup_enabled"` // Enable/disable automated backups
|
||||
BackupSchedule string `yaml:"backup_schedule"` // Cron schedule format
|
||||
BackupPath string `yaml:"backup_path"` // Directory to store backups
|
||||
BackupRetention int `yaml:"backup_retention"` // Days to keep backups
|
||||
|
||||
// Feature toggles for optional functionalities
|
||||
AuthEnabled bool `yaml:"auth_enabled"` // Enable/disable authentication system
|
||||
TamperLoggingEnabled bool `yaml:"tamper_logging_enabled"` // Enable/disable tamper-evident logging
|
||||
ClusteringEnabled bool `yaml:"clustering_enabled"` // Enable/disable clustering/gossip
|
||||
RateLimitingEnabled bool `yaml:"rate_limiting_enabled"` // Enable/disable rate limiting
|
||||
RevisionHistoryEnabled bool `yaml:"revision_history_enabled"` // Enable/disable revision history
|
||||
}
|
||||
|
||||
// Anonymous access control (Issue #5)
|
||||
AllowAnonymousRead bool `yaml:"allow_anonymous_read"` // Allow unauthenticated read access to KV endpoints
|
||||
AllowAnonymousWrite bool `yaml:"allow_anonymous_write"` // Allow unauthenticated write access to KV endpoints
|
||||
|
||||
// Cluster authentication (Issue #13)
|
||||
ClusterSecret string `yaml:"cluster_secret"` // Shared secret for cluster authentication (auto-generated if empty)
|
||||
ClusterTLSEnabled bool `yaml:"cluster_tls_enabled"` // Require TLS for inter-node communication
|
||||
ClusterTLSCertFile string `yaml:"cluster_tls_cert_file"` // Path to TLS certificate file
|
||||
ClusterTLSKeyFile string `yaml:"cluster_tls_key_file"` // Path to TLS private key file
|
||||
ClusterTLSSkipVerify bool `yaml:"cluster_tls_skip_verify"` // Skip TLS verification (insecure, for testing only)
|
||||
}
|
||||
|
@@ -22,4 +22,4 @@ func HashGroupName(groupname string) string {
|
||||
|
||||
func HashToken(token string) string {
|
||||
return HashSHA3512(token)
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user