Compare commits
28 Commits
4991a95008
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c70777181 | |||
| e1a66dc7df | |||
| 1c53988cbd | |||
| b4ef76f0d0 | |||
| 732e5aca11 | |||
| f45c878051 | |||
| c405ca27dd | |||
| 5abcbb7dda | |||
| 7ac7e77f1d | |||
| 921e7219ab | |||
| ad0c4dfc24 | |||
| abd8cef10a | |||
| 9779b30a65 | |||
| 507961716e | |||
| 96a6b5a4cf | |||
| df475bc85e | |||
| 3e64de18ed | |||
| 7c33e736f9 | |||
| 54e76d9304 | |||
| ed96137bad | |||
| 461edbc970 | |||
| a6da313dfc | |||
| f4683eeb73 | |||
| adc97943cd | |||
| 2259191e29 | |||
| 52cbd13941 | |||
| cf7669191e | |||
| 9ae433aae9 |
58
.cursor/rules/atlas-project-rules.mdc
Normal file
58
.cursor/rules/atlas-project-rules.mdc
Normal file
@@ -0,0 +1,58 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
##########################################
|
||||
# Atlas Project Standard Rules v1.0
|
||||
# ISO Ref: DevOps-Config-2025
|
||||
# Maintainer: Adastra - InfraOps Team
|
||||
##########################################
|
||||
|
||||
## Metadata
|
||||
- Template Name : Atlas Project Standard Rules
|
||||
- Version : 1.0
|
||||
- Maintainer : InfraOps Team
|
||||
- Last Updated : 2025-12-14
|
||||
|
||||
---
|
||||
|
||||
## Rule Categories
|
||||
|
||||
### 🔧 Indentation & Spacing
|
||||
[ ] CURSOR-001: Gunakan 2 spasi untuk indentation
|
||||
[ ] CURSOR-002: Hindari tab, gunakan spasi konsisten
|
||||
|
||||
### 📂 Naming Convention
|
||||
[ ] CURSOR-010: File harus pakai snake_case
|
||||
[ ] CURSOR-011: Folder pakai kebab-case
|
||||
[ ] CURSOR-012: Config file wajib ada suffix `.conf`
|
||||
[ ] CURSOR-013: Script file wajib ada suffix `.sh`
|
||||
[ ] CURSOR-014: Log file wajib ada suffix `.log`
|
||||
|
||||
### 🗂 File Structure
|
||||
[ ] CURSOR-020: Semua file harus ada header metadata
|
||||
[ ] CURSOR-021: Pisahkan config, script, dan log folder
|
||||
[ ] CURSOR-022: Tidak ada file kosong di repo
|
||||
|
||||
### ✅ Audit & Compliance
|
||||
[ ] CURSOR-030: Checklist harus lengkap sebelum commit
|
||||
[ ] CURSOR-031: Semua config tervalidasi linting
|
||||
[ ] CURSOR-032: Banner branding wajib ada di setiap template
|
||||
|
||||
### ⚠️ Error Handling
|
||||
[ ] CURSOR-040: Log error harus diarahkan ke folder `/logs`
|
||||
[ ] CURSOR-041: Tidak ada hardcoded path di script
|
||||
[ ] CURSOR-042: Semua service startup diverifikasi
|
||||
|
||||
---
|
||||
|
||||
## Compliance Scoring
|
||||
- [ ] 100% → Audit Passed
|
||||
- [ ] 80–99% → Minor Findings
|
||||
- [ ] <80% → Audit Failed
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
- Semua rule harus dipetakan ke ID unik (CURSOR-XXX).
|
||||
- Versi baru wajib update metadata & banner.
|
||||
- Checklist ini bisa dipakai lintas project untuk konsistensi.
|
||||
@@ -2,18 +2,38 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main", "develop" ]
|
||||
branches: ["main", "develop"]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
test-build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.22'
|
||||
go-version: "1.22"
|
||||
cache: true
|
||||
|
||||
- name: Go env
|
||||
run: |
|
||||
go version
|
||||
go env
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: Test
|
||||
run: go test ./...
|
||||
run: go test ./... -race -count=1
|
||||
|
||||
- name: Build
|
||||
run: go build ./cmd/...
|
||||
|
||||
- name: Quick static checks (optional)
|
||||
run: |
|
||||
# gofmt check (fails if formatting differs)
|
||||
test -z "$(gofmt -l . | head -n 1)"
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -2,6 +2,7 @@
|
||||
atlas-api
|
||||
atlas-tui
|
||||
atlas-agent
|
||||
pluto-api
|
||||
|
||||
# Go
|
||||
/vendor/
|
||||
@@ -15,3 +16,6 @@ atlas-agent
|
||||
|
||||
# Runtime
|
||||
*.log
|
||||
|
||||
# Temporary vdevs
|
||||
data/vdevs/
|
||||
|
||||
172
PlutoOS_SRS_v1.md
Normal file
172
PlutoOS_SRS_v1.md
Normal file
@@ -0,0 +1,172 @@
|
||||
SOFTWARE REQUIREMENTS SPECIFICATION (SRS)
|
||||
PlutoOS – Storage Controller Operating System (v1)
|
||||
|
||||
==================================================
|
||||
|
||||
1. INTRODUCTION
|
||||
--------------------------------------------------
|
||||
1.1 Purpose
|
||||
This document defines the functional and non-functional requirements for PlutoOS v1,
|
||||
a storage controller operating system built on Linux with ZFS as the core storage engine.
|
||||
It serves as the authoritative reference for development scope, validation, and acceptance.
|
||||
|
||||
1.2 Scope
|
||||
PlutoOS v1 provides:
|
||||
- ZFS pool, dataset, and ZVOL management
|
||||
- Storage services: SMB, NFS, iSCSI (ZVOL-backed)
|
||||
- Automated snapshot management
|
||||
- Role-Based Access Control (RBAC) and audit logging
|
||||
- Web-based GUI and local TUI
|
||||
- Monitoring and Prometheus-compatible metrics
|
||||
|
||||
The following are explicitly out of scope for v1:
|
||||
- High Availability (HA) or clustering
|
||||
- Multi-node replication
|
||||
- Object storage (S3)
|
||||
- Active Directory / LDAP integration
|
||||
|
||||
1.3 Definitions
|
||||
Dataset : ZFS filesystem
|
||||
ZVOL : ZFS block device
|
||||
LUN : Logical Unit Number exposed via iSCSI
|
||||
Job : Asynchronous long-running operation
|
||||
Desired State : Configuration stored in DB and applied atomically to system
|
||||
|
||||
==================================================
|
||||
|
||||
2. SYSTEM OVERVIEW
|
||||
--------------------------------------------------
|
||||
PlutoOS consists of:
|
||||
- Base OS : Minimal Linux (Ubuntu/Debian)
|
||||
- Data Plane : ZFS and storage services
|
||||
- Control Plane: Go backend with HTMX-based UI
|
||||
- Interfaces : Web GUI, TUI, Metrics endpoint
|
||||
|
||||
==================================================
|
||||
|
||||
3. USER CLASSES
|
||||
--------------------------------------------------
|
||||
Administrator : Full system and storage control
|
||||
Operator : Storage and service operations
|
||||
Viewer : Read-only access
|
||||
|
||||
==================================================
|
||||
|
||||
4. FUNCTIONAL REQUIREMENTS
|
||||
--------------------------------------------------
|
||||
|
||||
4.1 Authentication & Authorization
|
||||
- System SHALL require authentication for all management access
|
||||
- System SHALL enforce RBAC with predefined roles
|
||||
- Access SHALL be denied by default
|
||||
|
||||
4.2 ZFS Management
|
||||
- System SHALL list available disks (read-only)
|
||||
- System SHALL create, import, and export ZFS pools
|
||||
- System SHALL report pool health status
|
||||
- System SHALL create and manage datasets
|
||||
- System SHALL create ZVOLs for block storage
|
||||
- System SHALL support scrub operations with progress monitoring
|
||||
|
||||
4.3 Snapshot Management
|
||||
- System SHALL support manual snapshot creation
|
||||
- System SHALL support automated snapshot policies
|
||||
- System SHALL allow per-dataset snapshot enable/disable
|
||||
- System SHALL prune snapshots based on retention policy
|
||||
|
||||
4.4 SMB Service
|
||||
- System SHALL create SMB shares mapped to datasets
|
||||
- System SHALL manage share permissions
|
||||
- System SHALL apply configuration atomically
|
||||
- System SHALL reload service safely
|
||||
|
||||
4.5 NFS Service
|
||||
- System SHALL create NFS exports per dataset
|
||||
- System SHALL support RW/RO and client restrictions
|
||||
- System SHALL regenerate exports from desired state
|
||||
- System SHALL reload NFS exports safely
|
||||
|
||||
4.6 iSCSI Block Storage
|
||||
- System SHALL provision ZVOL-backed LUNs
|
||||
- System SHALL create iSCSI targets with IQN
|
||||
- System SHALL map LUNs to targets
|
||||
- System SHALL configure initiator ACLs
|
||||
- System SHALL expose connection instructions
|
||||
|
||||
4.7 Job Management
|
||||
- System SHALL execute long-running operations as jobs
|
||||
- System SHALL track job status and progress
|
||||
- System SHALL persist job history
|
||||
- Failed jobs SHALL not leave system inconsistent
|
||||
|
||||
4.8 Audit Logging
|
||||
- System SHALL log all mutating operations
|
||||
- Audit log SHALL record actor, action, resource, and timestamp
|
||||
- Audit log SHALL be immutable from the UI
|
||||
|
||||
4.9 Web GUI
|
||||
- System SHALL provide a web-based management interface
|
||||
- GUI SHALL support partial updates
|
||||
- GUI SHALL display system health and alerts
|
||||
- Destructive actions SHALL require confirmation
|
||||
|
||||
4.10 TUI
|
||||
- System SHALL provide a local console interface
|
||||
- TUI SHALL support initial system setup
|
||||
- TUI SHALL allow monitoring and maintenance operations
|
||||
- TUI SHALL function without web UI availability
|
||||
|
||||
4.11 Monitoring & Metrics
|
||||
- System SHALL expose /metrics in Prometheus format
|
||||
- System SHALL expose pool health and capacity metrics
|
||||
- System SHALL expose job failure metrics
|
||||
- GUI SHALL present a metrics summary
|
||||
|
||||
4.12 Update & Maintenance
|
||||
- System SHALL support safe update mechanisms
|
||||
- Configuration SHALL be backed up prior to updates
|
||||
- Maintenance mode SHALL disable user operations
|
||||
|
||||
==================================================
|
||||
|
||||
5. NON-FUNCTIONAL REQUIREMENTS
|
||||
--------------------------------------------------
|
||||
|
||||
5.1 Reliability
|
||||
- Storage operations SHALL be transactional where possible
|
||||
- System SHALL recover gracefully from partial failures
|
||||
|
||||
5.2 Performance
|
||||
- Management UI read operations SHOULD respond within 500ms
|
||||
- Background jobs SHALL not block UI responsiveness
|
||||
|
||||
5.3 Security
|
||||
- HTTPS SHALL be enforced for the web UI
|
||||
- Secrets SHALL NOT be logged in plaintext
|
||||
- Least-privilege access SHALL be enforced
|
||||
|
||||
5.4 Maintainability
|
||||
- Configuration SHALL be declarative
|
||||
- System SHALL provide diagnostic information for support
|
||||
|
||||
==================================================
|
||||
|
||||
6. CONSTRAINTS & ASSUMPTIONS
|
||||
--------------------------------------------------
|
||||
- Single-node controller
|
||||
- Linux kernel with ZFS support
|
||||
- Local storage only
|
||||
|
||||
==================================================
|
||||
|
||||
7. ACCEPTANCE CRITERIA (v1)
|
||||
--------------------------------------------------
|
||||
PlutoOS v1 is accepted when:
|
||||
- ZFS pool, dataset, share, and LUN lifecycle works end-to-end
|
||||
- Snapshot policies are active and observable
|
||||
- RBAC and audit logging are enforced
|
||||
- GUI, TUI, and metrics endpoints are functional
|
||||
- No manual configuration file edits are required
|
||||
|
||||
==================================================
|
||||
END OF DOCUMENT
|
||||
23
README.md
23
README.md
@@ -1,6 +1,6 @@
|
||||
# atlasOS
|
||||
# AtlasOS
|
||||
|
||||
atlasOS is an appliance-style storage controller build by Adastra
|
||||
AtlasOS is an appliance-style storage controller build by Adastra
|
||||
|
||||
**v1 Focus**
|
||||
- ZFS storage engine
|
||||
@@ -11,3 +11,22 @@ atlasOS is an appliance-style storage controller build by Adastra
|
||||
- Prometheus metrics
|
||||
|
||||
> This repository contains the management plane and appliance tooling.
|
||||
|
||||
## Quick Installation
|
||||
|
||||
### Standard Installation (with internet)
|
||||
```bash
|
||||
sudo ./installer/install.sh
|
||||
```
|
||||
|
||||
### Airgap Installation (offline)
|
||||
```bash
|
||||
# Step 1: Download bundle (on internet-connected system)
|
||||
sudo ./installer/bundle-downloader.sh ./atlas-bundle
|
||||
|
||||
# Step 2: Transfer bundle to airgap system
|
||||
# Step 3: Install on airgap system
|
||||
sudo ./installer/install.sh --offline-bundle /path/to/atlas-bundle
|
||||
```
|
||||
|
||||
See `installer/README.md` and `docs/INSTALLATION.md` for detailed instructions.
|
||||
|
||||
BIN
data/atlas.db
Normal file
BIN
data/atlas.db
Normal file
Binary file not shown.
174
docs/AIRGAP_INSTALLATION.md
Normal file
174
docs/AIRGAP_INSTALLATION.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Airgap Installation Guide for AtlasOS
|
||||
|
||||
## Overview
|
||||
|
||||
AtlasOS installer supports airgap (offline) installation for data centers without internet access. All required packages and dependencies are bundled into a single directory that can be transferred to the airgap system.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Step 1: Download Bundle (On System with Internet)
|
||||
|
||||
On a system with internet access and Ubuntu 24.04:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone <repository-url>
|
||||
cd atlas
|
||||
|
||||
# Run bundle downloader (requires root)
|
||||
sudo ./installer/bundle-downloader.sh ./atlas-bundle
|
||||
```
|
||||
|
||||
This will create a directory `./atlas-bundle` containing:
|
||||
- All required .deb packages (~100-200 packages)
|
||||
- All dependencies
|
||||
- Go binary (fallback)
|
||||
- Manifest and README files
|
||||
|
||||
**Estimated bundle size:** 500MB - 1GB
|
||||
|
||||
### Step 2: Transfer Bundle to Airgap System
|
||||
|
||||
Transfer the entire bundle directory to your airgap system using:
|
||||
- USB drive
|
||||
- Internal network (if available)
|
||||
- Physical media
|
||||
|
||||
```bash
|
||||
# Example: Copy to USB drive
|
||||
cp -r ./atlas-bundle /media/usb/
|
||||
|
||||
# On airgap system: Copy from USB
|
||||
cp -r /media/usb/atlas-bundle /tmp/
|
||||
```
|
||||
|
||||
### Step 3: Install on Airgap System
|
||||
|
||||
On the airgap system (Ubuntu 24.04):
|
||||
|
||||
```bash
|
||||
# Navigate to bundle directory
|
||||
cd /tmp/atlas-bundle
|
||||
|
||||
# Run installer with offline bundle
|
||||
cd /path/to/atlas
|
||||
sudo ./installer/install.sh --offline-bundle /tmp/atlas-bundle
|
||||
```
|
||||
|
||||
## Bundle Contents
|
||||
|
||||
The bundle includes:
|
||||
|
||||
### Main Packages
|
||||
- **Build Tools**: build-essential, git, curl, wget
|
||||
- **ZFS**: zfsutils-linux, zfs-zed, zfs-initramfs
|
||||
- **Storage Services**: samba, samba-common-bin, nfs-kernel-server, rpcbind
|
||||
- **iSCSI**: targetcli-fb
|
||||
- **Database**: sqlite3, libsqlite3-dev
|
||||
- **Go Compiler**: golang-go
|
||||
- **Utilities**: openssl, net-tools, iproute2
|
||||
|
||||
### Dependencies
|
||||
All transitive dependencies are automatically included.
|
||||
|
||||
## Verification
|
||||
|
||||
Before transferring, verify the bundle:
|
||||
|
||||
```bash
|
||||
# Count .deb files (should be 100-200)
|
||||
find ./atlas-bundle -name "*.deb" | wc -l
|
||||
|
||||
# Check manifest
|
||||
cat ./atlas-bundle/MANIFEST.txt
|
||||
|
||||
# Check total size
|
||||
du -sh ./atlas-bundle
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Missing Dependencies
|
||||
|
||||
If installation fails with dependency errors:
|
||||
|
||||
1. Ensure all .deb files are present in bundle
|
||||
2. Check that bundle was created on Ubuntu 24.04
|
||||
3. Verify system architecture matches (amd64/arm64)
|
||||
|
||||
### Go Installation Issues
|
||||
|
||||
If Go is not found after installation:
|
||||
|
||||
1. Check if `golang-go` package is installed: `dpkg -l | grep golang-go`
|
||||
2. If missing, the bundle includes `go.tar.gz` as fallback
|
||||
3. Installer will automatically extract it if needed
|
||||
|
||||
### Package Conflicts
|
||||
|
||||
If you encounter package conflicts:
|
||||
|
||||
```bash
|
||||
# Fix broken packages
|
||||
sudo apt-get install -f -y
|
||||
|
||||
# Or manually install specific packages
|
||||
sudo dpkg -i /path/to/bundle/*.deb
|
||||
sudo apt-get install -f -y
|
||||
```
|
||||
|
||||
## Bundle Maintenance
|
||||
|
||||
### Updating Bundle
|
||||
|
||||
To update the bundle with newer packages:
|
||||
|
||||
1. Run `./installer/bundle-downloader.sh` again on internet-connected system
|
||||
2. This will download latest versions
|
||||
3. Transfer new bundle to airgap system
|
||||
|
||||
### Bundle Size Optimization
|
||||
|
||||
To reduce bundle size (optional):
|
||||
|
||||
```bash
|
||||
# Remove unnecessary packages (be careful!)
|
||||
# Only remove if you're certain they're not needed
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Verify bundle integrity before transferring
|
||||
- Use secure transfer methods (encrypted USB, secure network)
|
||||
- Keep bundle in secure location on airgap system
|
||||
- Verify package signatures if possible
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Custom Bundle Location
|
||||
|
||||
```bash
|
||||
# Download to custom location
|
||||
sudo ./installer/bundle-downloader.sh /opt/atlas-bundles/ubuntu24.04
|
||||
|
||||
# Install from custom location
|
||||
sudo ./installer/install.sh --offline-bundle /opt/atlas-bundles/ubuntu24.04
|
||||
```
|
||||
|
||||
### Partial Bundle (if some packages already installed)
|
||||
|
||||
If some packages are already installed on airgap system:
|
||||
|
||||
```bash
|
||||
# Installer will skip already-installed packages
|
||||
# Missing packages will be installed from bundle
|
||||
sudo ./installer/install.sh --offline-bundle /path/to/bundle
|
||||
```
|
||||
|
||||
## Support
|
||||
|
||||
For issues with airgap installation:
|
||||
1. Check installation logs
|
||||
2. Verify bundle completeness
|
||||
3. Ensure Ubuntu 24.04 compatibility
|
||||
4. Review MANIFEST.txt for package list
|
||||
278
docs/API_SECURITY.md
Normal file
278
docs/API_SECURITY.md
Normal file
@@ -0,0 +1,278 @@
|
||||
# API Security & Rate Limiting
|
||||
|
||||
## Overview
|
||||
|
||||
AtlasOS implements comprehensive API security measures including rate limiting, security headers, CORS protection, and request validation to protect the API from abuse and attacks.
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
### Token Bucket Algorithm
|
||||
|
||||
The rate limiter uses a token bucket algorithm:
|
||||
- **Default Rate**: 100 requests per minute per client
|
||||
- **Window**: 60 seconds
|
||||
- **Token Refill**: Tokens are refilled based on elapsed time
|
||||
- **Per-Client**: Rate limiting is applied per IP address or user ID
|
||||
|
||||
### Rate Limit Headers
|
||||
|
||||
All responses include rate limit headers:
|
||||
|
||||
```
|
||||
X-RateLimit-Limit: 100
|
||||
X-RateLimit-Window: 60
|
||||
```
|
||||
|
||||
### Rate Limit Exceeded
|
||||
|
||||
When rate limit is exceeded, the API returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "SERVICE_UNAVAILABLE",
|
||||
"message": "rate limit exceeded",
|
||||
"details": "too many requests, please try again later"
|
||||
}
|
||||
```
|
||||
|
||||
**HTTP Status**: `429 Too Many Requests`
|
||||
|
||||
### Client Identification
|
||||
|
||||
Rate limiting uses different keys based on authentication:
|
||||
|
||||
- **Authenticated Users**: `user:{user_id}` - More granular per-user limiting
|
||||
- **Unauthenticated**: `ip:{ip_address}` - IP-based limiting
|
||||
|
||||
### Public Endpoints
|
||||
|
||||
Public endpoints (login, health checks) are excluded from rate limiting to ensure availability.
|
||||
|
||||
## Security Headers
|
||||
|
||||
All responses include security headers:
|
||||
|
||||
### X-Content-Type-Options
|
||||
- **Value**: `nosniff`
|
||||
- **Purpose**: Prevents MIME type sniffing
|
||||
|
||||
### X-Frame-Options
|
||||
- **Value**: `DENY`
|
||||
- **Purpose**: Prevents clickjacking attacks
|
||||
|
||||
### X-XSS-Protection
|
||||
- **Value**: `1; mode=block`
|
||||
- **Purpose**: Enables XSS filtering in browsers
|
||||
|
||||
### Referrer-Policy
|
||||
- **Value**: `strict-origin-when-cross-origin`
|
||||
- **Purpose**: Controls referrer information
|
||||
|
||||
### Permissions-Policy
|
||||
- **Value**: `geolocation=(), microphone=(), camera=()`
|
||||
- **Purpose**: Disables unnecessary browser features
|
||||
|
||||
### Strict-Transport-Security (HSTS)
|
||||
- **Value**: `max-age=31536000; includeSubDomains`
|
||||
- **Purpose**: Forces HTTPS connections (only on HTTPS)
|
||||
- **Note**: Only added when request is over TLS
|
||||
|
||||
### Content-Security-Policy (CSP)
|
||||
- **Value**: `default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data:; font-src 'self' https://cdn.jsdelivr.net; connect-src 'self';`
|
||||
- **Purpose**: Restricts resource loading to prevent XSS
|
||||
|
||||
## CORS (Cross-Origin Resource Sharing)
|
||||
|
||||
### Allowed Origins
|
||||
|
||||
By default, the following origins are allowed:
|
||||
|
||||
- `http://localhost:8080`
|
||||
- `http://localhost:3000`
|
||||
- `http://127.0.0.1:8080`
|
||||
- Same-origin requests (no Origin header)
|
||||
|
||||
### CORS Headers
|
||||
|
||||
When a request comes from an allowed origin:
|
||||
|
||||
```
|
||||
Access-Control-Allow-Origin: http://localhost:8080
|
||||
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH, OPTIONS
|
||||
Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With
|
||||
Access-Control-Allow-Credentials: true
|
||||
Access-Control-Max-Age: 3600
|
||||
```
|
||||
|
||||
### Preflight Requests
|
||||
|
||||
OPTIONS requests are handled automatically:
|
||||
|
||||
- **Status**: `204 No Content`
|
||||
- **Headers**: All CORS headers included
|
||||
- **Purpose**: Browser preflight checks
|
||||
|
||||
## Request Size Limits
|
||||
|
||||
### Maximum Request Body Size
|
||||
|
||||
- **Limit**: 10 MB (10,485,760 bytes)
|
||||
- **Enforcement**: Automatic via `http.MaxBytesReader`
|
||||
- **Error**: Returns `413 Request Entity Too Large` if exceeded
|
||||
|
||||
### Content-Type Validation
|
||||
|
||||
POST, PUT, and PATCH requests must include a valid `Content-Type` header:
|
||||
|
||||
**Allowed Types:**
|
||||
- `application/json`
|
||||
- `application/x-www-form-urlencoded`
|
||||
- `multipart/form-data`
|
||||
|
||||
**Error Response:**
|
||||
```json
|
||||
{
|
||||
"code": "BAD_REQUEST",
|
||||
"message": "Content-Type must be application/json"
|
||||
}
|
||||
```
|
||||
|
||||
## Middleware Chain Order
|
||||
|
||||
Security middleware is applied in the following order (outer to inner):
|
||||
|
||||
1. **CORS** - Handles preflight requests
|
||||
2. **Security Headers** - Adds security headers
|
||||
3. **Request Size Limit** - Enforces 10MB limit
|
||||
4. **Content-Type Validation** - Validates request content type
|
||||
5. **Rate Limiting** - Enforces rate limits
|
||||
6. **Error Recovery** - Catches panics
|
||||
7. **Request ID** - Generates request IDs
|
||||
8. **Logging** - Logs requests
|
||||
9. **Audit** - Records audit logs
|
||||
10. **Authentication** - Validates JWT tokens
|
||||
11. **Routes** - Handles requests
|
||||
|
||||
## Public Endpoints
|
||||
|
||||
The following endpoints are excluded from certain security checks:
|
||||
|
||||
- `/api/v1/auth/login` - Rate limiting, Content-Type validation
|
||||
- `/api/v1/auth/logout` - Rate limiting, Content-Type validation
|
||||
- `/healthz` - Rate limiting, Content-Type validation
|
||||
- `/metrics` - Rate limiting, Content-Type validation
|
||||
- `/api/docs` - Rate limiting, Content-Type validation
|
||||
- `/api/openapi.yaml` - Rate limiting, Content-Type validation
|
||||
|
||||
## Best Practices
|
||||
|
||||
### For API Consumers
|
||||
|
||||
1. **Respect Rate Limits**: Implement exponential backoff when rate limited
|
||||
2. **Use Authentication**: Authenticated users get better rate limits
|
||||
3. **Include Content-Type**: Always include `Content-Type: application/json`
|
||||
4. **Handle Errors**: Check for `429` status and retry after delay
|
||||
5. **Request Size**: Keep request bodies under 10MB
|
||||
|
||||
### For Administrators
|
||||
|
||||
1. **Monitor Rate Limits**: Check logs for rate limit violations
|
||||
2. **Adjust Limits**: Modify rate limit values in code if needed
|
||||
3. **CORS Configuration**: Update allowed origins for production
|
||||
4. **HTTPS**: Always use HTTPS in production for HSTS
|
||||
5. **Security Headers**: Review CSP policy for your use case
|
||||
|
||||
## Configuration
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
Rate limits are currently hardcoded but can be configured:
|
||||
|
||||
```go
|
||||
// In rate_limit.go
|
||||
rateLimiter := NewRateLimiter(100, time.Minute) // 100 req/min
|
||||
```
|
||||
|
||||
### CORS Origins
|
||||
|
||||
Update allowed origins in `security_middleware.go`:
|
||||
|
||||
```go
|
||||
allowedOrigins := []string{
|
||||
"https://yourdomain.com",
|
||||
"https://app.yourdomain.com",
|
||||
}
|
||||
```
|
||||
|
||||
### Request Size Limit
|
||||
|
||||
Modify in `app.go`:
|
||||
|
||||
```go
|
||||
a.requestSizeMiddleware(10*1024*1024) // 10MB
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
### Rate Limit Exceeded
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "SERVICE_UNAVAILABLE",
|
||||
"message": "rate limit exceeded",
|
||||
"details": "too many requests, please try again later"
|
||||
}
|
||||
```
|
||||
|
||||
**Status**: `429 Too Many Requests`
|
||||
|
||||
### Request Too Large
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "BAD_REQUEST",
|
||||
"message": "request body too large"
|
||||
}
|
||||
```
|
||||
|
||||
**Status**: `413 Request Entity Too Large`
|
||||
|
||||
### Invalid Content-Type
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "BAD_REQUEST",
|
||||
"message": "Content-Type must be application/json"
|
||||
}
|
||||
```
|
||||
|
||||
**Status**: `400 Bad Request`
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Rate Limit Metrics
|
||||
|
||||
Monitor rate limit violations:
|
||||
|
||||
- Check audit logs for rate limit events
|
||||
- Monitor `429` status codes in access logs
|
||||
- Track rate limit headers in responses
|
||||
|
||||
### Security Events
|
||||
|
||||
Monitor for security-related events:
|
||||
|
||||
- Invalid Content-Type headers
|
||||
- Request size violations
|
||||
- CORS violations (check server logs)
|
||||
- Authentication failures
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Configurable Rate Limits**: Environment variable configuration
|
||||
2. **Per-Endpoint Limits**: Different limits for different endpoints
|
||||
3. **IP Whitelisting**: Bypass rate limits for trusted IPs
|
||||
4. **Rate Limit Metrics**: Prometheus metrics for rate limiting
|
||||
5. **Distributed Rate Limiting**: Redis-based for multi-instance deployments
|
||||
6. **Advanced CORS**: Configurable CORS via environment variables
|
||||
7. **Request Timeout**: Configurable request timeout limits
|
||||
125
docs/BACKGROUND_JOBS.md
Normal file
125
docs/BACKGROUND_JOBS.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# Background Job System
|
||||
|
||||
The AtlasOS API includes a background job system that automatically executes snapshot policies and manages long-running operations.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components
|
||||
|
||||
1. **Job Manager** (`internal/job/manager.go`)
|
||||
- Tracks job lifecycle (pending, running, completed, failed, cancelled)
|
||||
- Stores job metadata and progress
|
||||
- Thread-safe job operations
|
||||
|
||||
2. **Snapshot Scheduler** (`internal/snapshot/scheduler.go`)
|
||||
- Automatically creates snapshots based on policies
|
||||
- Prunes old snapshots based on retention rules
|
||||
- Runs every 15 minutes by default
|
||||
|
||||
3. **Integration**
|
||||
- Scheduler starts automatically when API server starts
|
||||
- Gracefully stops on server shutdown
|
||||
- Jobs are accessible via API endpoints
|
||||
|
||||
## How It Works
|
||||
|
||||
### Snapshot Creation
|
||||
|
||||
The scheduler checks all enabled snapshot policies every 15 minutes and:
|
||||
|
||||
1. **Frequent snapshots**: Creates every 15 minutes if `frequent > 0`
|
||||
2. **Hourly snapshots**: Creates every hour if `hourly > 0`
|
||||
3. **Daily snapshots**: Creates daily if `daily > 0`
|
||||
4. **Weekly snapshots**: Creates weekly if `weekly > 0`
|
||||
5. **Monthly snapshots**: Creates monthly if `monthly > 0`
|
||||
6. **Yearly snapshots**: Creates yearly if `yearly > 0`
|
||||
|
||||
Snapshot names follow the pattern: `{type}-{timestamp}` (e.g., `hourly-20241214-143000`)
|
||||
|
||||
### Snapshot Pruning
|
||||
|
||||
When `autoprune` is enabled, the scheduler:
|
||||
|
||||
1. Groups snapshots by type (frequent, hourly, daily, etc.)
|
||||
2. Sorts by creation time (newest first)
|
||||
3. Keeps only the number specified in the policy
|
||||
4. Deletes older snapshots that exceed the retention count
|
||||
|
||||
### Job Tracking
|
||||
|
||||
Every snapshot operation creates a job that tracks:
|
||||
- Status (pending → running → completed/failed)
|
||||
- Progress (0-100%)
|
||||
- Error messages (if failed)
|
||||
- Timestamps (created, started, completed)
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### List Jobs
|
||||
```bash
|
||||
GET /api/v1/jobs
|
||||
GET /api/v1/jobs?status=running
|
||||
```
|
||||
|
||||
### Get Job
|
||||
```bash
|
||||
GET /api/v1/jobs/{id}
|
||||
```
|
||||
|
||||
### Cancel Job
|
||||
```bash
|
||||
POST /api/v1/jobs/{id}/cancel
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The scheduler interval is hardcoded to 15 minutes. To change it, modify:
|
||||
|
||||
```go
|
||||
// In internal/httpapp/app.go
|
||||
scheduler.Start(15 * time.Minute) // Change interval here
|
||||
```
|
||||
|
||||
## Example Workflow
|
||||
|
||||
1. **Create a snapshot policy:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/snapshot-policies \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"dataset": "pool/dataset",
|
||||
"hourly": 24,
|
||||
"daily": 7,
|
||||
"autosnap": true,
|
||||
"autoprune": true
|
||||
}'
|
||||
```
|
||||
|
||||
2. **Scheduler automatically:**
|
||||
- Creates hourly snapshots (keeps 24)
|
||||
- Creates daily snapshots (keeps 7)
|
||||
- Prunes old snapshots beyond retention
|
||||
|
||||
3. **Monitor jobs:**
|
||||
```bash
|
||||
curl http://localhost:8080/api/v1/jobs
|
||||
```
|
||||
|
||||
## Job Statuses
|
||||
|
||||
- `pending`: Job created but not started
|
||||
- `running`: Job is currently executing
|
||||
- `completed`: Job finished successfully
|
||||
- `failed`: Job encountered an error
|
||||
- `cancelled`: Job was cancelled by user
|
||||
|
||||
## Notes
|
||||
|
||||
- Jobs are stored in-memory (will be lost on restart)
|
||||
- Scheduler runs in a background goroutine
|
||||
- Snapshot operations are synchronous (blocking)
|
||||
- For production, consider:
|
||||
- Database persistence for jobs
|
||||
- Async job execution with worker pool
|
||||
- Job history retention policies
|
||||
- Metrics/alerting for failed jobs
|
||||
307
docs/BACKUP_RESTORE.md
Normal file
307
docs/BACKUP_RESTORE.md
Normal file
@@ -0,0 +1,307 @@
|
||||
# Configuration Backup & Restore
|
||||
|
||||
## Overview
|
||||
|
||||
AtlasOS provides comprehensive configuration backup and restore functionality, allowing you to save and restore all system configurations including users, storage services (SMB/NFS/iSCSI), and snapshot policies.
|
||||
|
||||
## Features
|
||||
|
||||
- **Full Configuration Backup**: Backs up all system configurations
|
||||
- **Compressed Archives**: Backups are stored as gzipped tar archives
|
||||
- **Metadata Tracking**: Each backup includes metadata (ID, timestamp, description, size)
|
||||
- **Verification**: Verify backup integrity before restore
|
||||
- **Dry Run**: Test restore operations without making changes
|
||||
- **Selective Restore**: Restore specific components or full system
|
||||
|
||||
## Configuration
|
||||
|
||||
Set the backup directory using the `ATLAS_BACKUP_DIR` environment variable:
|
||||
|
||||
```bash
|
||||
export ATLAS_BACKUP_DIR=/var/lib/atlas/backups
|
||||
./atlas-api
|
||||
```
|
||||
|
||||
If not set, defaults to `data/backups` in the current directory.
|
||||
|
||||
## Backup Contents
|
||||
|
||||
A backup includes:
|
||||
|
||||
- **Users**: All user accounts (passwords cannot be restored - users must reset)
|
||||
- **SMB Shares**: All SMB/CIFS share configurations
|
||||
- **NFS Exports**: All NFS export configurations
|
||||
- **iSCSI Targets**: All iSCSI targets and LUN mappings
|
||||
- **Snapshot Policies**: All automated snapshot policies
|
||||
- **System Config**: Database path and other system settings
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Create Backup
|
||||
|
||||
**POST** `/api/v1/backups`
|
||||
|
||||
Creates a new backup of all system configurations.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"description": "Backup before major changes"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": "backup-1703123456",
|
||||
"created_at": "2024-12-20T10:30:56Z",
|
||||
"version": "1.0",
|
||||
"description": "Backup before major changes",
|
||||
"size": 24576
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/backups \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"description": "Weekly backup"}'
|
||||
```
|
||||
|
||||
### List Backups
|
||||
|
||||
**GET** `/api/v1/backups`
|
||||
|
||||
Lists all available backups.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "backup-1703123456",
|
||||
"created_at": "2024-12-20T10:30:56Z",
|
||||
"version": "1.0",
|
||||
"description": "Weekly backup",
|
||||
"size": 24576
|
||||
},
|
||||
{
|
||||
"id": "backup-1703037056",
|
||||
"created_at": "2024-12-19T10:30:56Z",
|
||||
"version": "1.0",
|
||||
"description": "",
|
||||
"size": 18432
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl -X GET http://localhost:8080/api/v1/backups \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
### Get Backup Details
|
||||
|
||||
**GET** `/api/v1/backups/{id}`
|
||||
|
||||
Retrieves metadata for a specific backup.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": "backup-1703123456",
|
||||
"created_at": "2024-12-20T10:30:56Z",
|
||||
"version": "1.0",
|
||||
"description": "Weekly backup",
|
||||
"size": 24576
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl -X GET http://localhost:8080/api/v1/backups/backup-1703123456 \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
### Verify Backup
|
||||
|
||||
**GET** `/api/v1/backups/{id}?verify=true`
|
||||
|
||||
Verifies that a backup file is valid and can be restored.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"message": "backup is valid",
|
||||
"backup_id": "backup-1703123456",
|
||||
"metadata": {
|
||||
"id": "backup-1703123456",
|
||||
"created_at": "2024-12-20T10:30:56Z",
|
||||
"version": "1.0",
|
||||
"description": "Weekly backup",
|
||||
"size": 24576
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl -X GET "http://localhost:8080/api/v1/backups/backup-1703123456?verify=true" \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
### Restore Backup
|
||||
|
||||
**POST** `/api/v1/backups/{id}/restore`
|
||||
|
||||
Restores configuration from a backup.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"dry_run": false
|
||||
}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `dry_run` (optional): If `true`, shows what would be restored without making changes
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"message": "backup restored successfully",
|
||||
"backup_id": "backup-1703123456"
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
# Dry run (test restore)
|
||||
curl -X POST http://localhost:8080/api/v1/backups/backup-1703123456/restore \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"dry_run": true}'
|
||||
|
||||
# Actual restore
|
||||
curl -X POST http://localhost:8080/api/v1/backups/backup-1703123456/restore \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"dry_run": false}'
|
||||
```
|
||||
|
||||
### Delete Backup
|
||||
|
||||
**DELETE** `/api/v1/backups/{id}`
|
||||
|
||||
Deletes a backup file and its metadata.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"message": "backup deleted",
|
||||
"backup_id": "backup-1703123456"
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl -X DELETE http://localhost:8080/api/v1/backups/backup-1703123456 \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
## Restore Process
|
||||
|
||||
When restoring a backup:
|
||||
|
||||
1. **Verification**: Backup is verified before restore
|
||||
2. **User Restoration**:
|
||||
- Users are restored with temporary passwords
|
||||
- Default admin user (user-1) is skipped
|
||||
- Users must reset their passwords after restore
|
||||
3. **Storage Services**:
|
||||
- SMB shares, NFS exports, and iSCSI targets are restored
|
||||
- Existing configurations are skipped (not overwritten)
|
||||
- Service configurations are automatically applied
|
||||
4. **Snapshot Policies**:
|
||||
- Policies are restored by dataset
|
||||
- Existing policies are skipped
|
||||
5. **Service Application**:
|
||||
- Samba, NFS, and iSCSI services are reconfigured
|
||||
- Errors are logged but don't fail the restore
|
||||
|
||||
## Backup File Format
|
||||
|
||||
Backups are stored as gzipped tar archives containing:
|
||||
|
||||
- `metadata.json`: Backup metadata (ID, timestamp, description, etc.)
|
||||
- `config.json`: All configuration data (users, shares, exports, targets, policies)
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Regular Backups**: Create backups before major configuration changes
|
||||
2. **Verify Before Restore**: Always verify backups before restoring
|
||||
3. **Test Restores**: Use dry run to test restore operations
|
||||
4. **Backup Retention**: Keep multiple backups for different time periods
|
||||
5. **Offsite Storage**: Copy backups to external storage for disaster recovery
|
||||
6. **Password Management**: Users must reset passwords after restore
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Passwords**: User passwords cannot be restored (security feature)
|
||||
- **ZFS Data**: Backups only include configuration, not ZFS pool/dataset data
|
||||
- **Audit Logs**: Audit logs are not included in backups
|
||||
- **Jobs**: Background jobs are not included in backups
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **Invalid Backup**: Verification fails if backup is corrupted
|
||||
- **Missing Resources**: Restore skips resources that already exist
|
||||
- **Service Errors**: Service configuration errors are logged but don't fail restore
|
||||
- **Partial Restore**: Restore continues even if some components fail
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Backup Storage**: Store backups in secure locations
|
||||
2. **Access Control**: Backup endpoints require authentication
|
||||
3. **Password Security**: Passwords are never included in backups
|
||||
4. **Encryption**: Consider encrypting backups for sensitive environments
|
||||
|
||||
## Example Workflow
|
||||
|
||||
```bash
|
||||
# 1. Create backup before changes
|
||||
BACKUP_ID=$(curl -X POST http://localhost:8080/api/v1/backups \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"description": "Before major changes"}' \
|
||||
| jq -r '.id')
|
||||
|
||||
# 2. Verify backup
|
||||
curl -X GET "http://localhost:8080/api/v1/backups/$BACKUP_ID?verify=true" \
|
||||
-H "Authorization: Bearer <token>"
|
||||
|
||||
# 3. Make configuration changes
|
||||
# ... make changes ...
|
||||
|
||||
# 4. Test restore (dry run)
|
||||
curl -X POST "http://localhost:8080/api/v1/backups/$BACKUP_ID/restore" \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"dry_run": true}'
|
||||
|
||||
# 5. Restore if needed
|
||||
curl -X POST "http://localhost:8080/api/v1/backups/$BACKUP_ID/restore" \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"dry_run": false}'
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- **Scheduled Backups**: Automatic backup scheduling
|
||||
- **Incremental Backups**: Only backup changes since last backup
|
||||
- **Backup Encryption**: Encrypt backup files
|
||||
- **Remote Storage**: Support for S3, FTP, etc.
|
||||
- **Backup Compression**: Additional compression options
|
||||
- **Selective Restore**: Restore specific components only
|
||||
84
docs/DATABASE.md
Normal file
84
docs/DATABASE.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Database Persistence
|
||||
|
||||
## Overview
|
||||
|
||||
AtlasOS now supports SQLite-based database persistence for configuration and state management. The database layer is optional - if no database path is provided, the system operates in in-memory mode (data is lost on restart).
|
||||
|
||||
## Configuration
|
||||
|
||||
Set the `ATLAS_DB_PATH` environment variable to enable database persistence:
|
||||
|
||||
```bash
|
||||
export ATLAS_DB_PATH=/var/lib/atlas/atlas.db
|
||||
./atlas-api
|
||||
```
|
||||
|
||||
If not set, the system defaults to `data/atlas.db` in the current directory.
|
||||
|
||||
## Database Schema
|
||||
|
||||
The database includes tables for:
|
||||
|
||||
- **users** - User accounts and authentication
|
||||
- **audit_logs** - Audit trail with indexes for efficient querying
|
||||
- **smb_shares** - SMB/CIFS share configurations
|
||||
- **nfs_exports** - NFS export configurations
|
||||
- **iscsi_targets** - iSCSI target configurations
|
||||
- **iscsi_luns** - iSCSI LUN mappings
|
||||
- **snapshot_policies** - Automated snapshot policies
|
||||
|
||||
## Current Status
|
||||
|
||||
✅ **Database Infrastructure**: Complete
|
||||
- SQLite database connection and migration system
|
||||
- Schema definitions for all entities
|
||||
- Optional database mode (falls back to in-memory if not configured)
|
||||
|
||||
⏳ **Store Migration**: In Progress
|
||||
- Stores currently use in-memory implementations
|
||||
- Database-backed implementations can be added incrementally
|
||||
- Pattern established for migration
|
||||
|
||||
## Migration Pattern
|
||||
|
||||
To migrate a store to use the database:
|
||||
|
||||
1. Add database field to store struct
|
||||
2. Update `New*Store()` to accept `*db.DB` parameter
|
||||
3. Implement database queries in CRUD methods
|
||||
4. Update `app.go` to pass database to store constructor
|
||||
|
||||
Example pattern:
|
||||
|
||||
```go
|
||||
type UserStore struct {
|
||||
db *db.DB
|
||||
mu sync.RWMutex
|
||||
// ... other fields
|
||||
}
|
||||
|
||||
func NewUserStore(db *db.DB, auth *Service) *UserStore {
|
||||
// Initialize with database
|
||||
}
|
||||
|
||||
func (s *UserStore) Create(...) (*User, error) {
|
||||
// Use database instead of in-memory map
|
||||
_, err := s.db.Exec("INSERT INTO users ...")
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
- **Persistence**: Configuration survives restarts
|
||||
- **Audit Trail**: Historical audit logs preserved
|
||||
- **Scalability**: Can migrate to PostgreSQL/MySQL later
|
||||
- **Backup**: Simple file-based backup (SQLite database file)
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Migrate user store to database (highest priority for security)
|
||||
2. Migrate audit log store (for historical tracking)
|
||||
3. Migrate storage service stores (SMB/NFS/iSCSI)
|
||||
4. Migrate snapshot policy store
|
||||
5. Add database backup/restore utilities
|
||||
242
docs/ERROR_HANDLING.md
Normal file
242
docs/ERROR_HANDLING.md
Normal file
@@ -0,0 +1,242 @@
|
||||
# Error Handling & Recovery
|
||||
|
||||
## Overview
|
||||
|
||||
AtlasOS implements comprehensive error handling with structured error responses, graceful degradation, and automatic recovery mechanisms to ensure system reliability and good user experience.
|
||||
|
||||
## Error Types
|
||||
|
||||
### Structured API Errors
|
||||
|
||||
All API errors follow a consistent structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "NOT_FOUND",
|
||||
"message": "dataset not found",
|
||||
"details": "tank/missing"
|
||||
}
|
||||
```
|
||||
|
||||
### Error Codes
|
||||
|
||||
- `INTERNAL_ERROR` - Unexpected server errors (500)
|
||||
- `NOT_FOUND` - Resource not found (404)
|
||||
- `BAD_REQUEST` - Invalid request parameters (400)
|
||||
- `CONFLICT` - Resource conflict (409)
|
||||
- `UNAUTHORIZED` - Authentication required (401)
|
||||
- `FORBIDDEN` - Insufficient permissions (403)
|
||||
- `SERVICE_UNAVAILABLE` - Service temporarily unavailable (503)
|
||||
- `VALIDATION_ERROR` - Input validation failed (400)
|
||||
|
||||
## Error Handling Patterns
|
||||
|
||||
### 1. Structured Error Responses
|
||||
|
||||
All errors use the `errors.APIError` type for consistent formatting:
|
||||
|
||||
```go
|
||||
if resource == nil {
|
||||
writeError(w, errors.ErrNotFound("dataset").WithDetails(datasetName))
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Graceful Degradation
|
||||
|
||||
Service operations (SMB/NFS/iSCSI) use graceful degradation:
|
||||
|
||||
- **Desired State Stored**: Configuration is always stored in the store
|
||||
- **Service Application**: Service configuration is applied asynchronously
|
||||
- **Non-Blocking**: Service failures don't fail API requests
|
||||
- **Retry Ready**: Failed operations can be retried later
|
||||
|
||||
Example:
|
||||
```go
|
||||
// Store the configuration (always succeeds)
|
||||
share, err := a.smbStore.Create(...)
|
||||
|
||||
// Apply to service (may fail, but doesn't block)
|
||||
if err := a.smbService.ApplyConfiguration(shares); err != nil {
|
||||
// Log but don't fail - desired state is stored
|
||||
log.Printf("SMB service configuration failed (non-fatal): %v", err)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Panic Recovery
|
||||
|
||||
All HTTP handlers are wrapped with panic recovery middleware:
|
||||
|
||||
```go
|
||||
func (a *App) errorMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer recoverPanic(w, r)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Panics are caught and converted to proper error responses instead of crashing the server.
|
||||
|
||||
### 4. Atomic Operations with Rollback
|
||||
|
||||
Service configuration operations are atomic with automatic rollback:
|
||||
|
||||
1. **Write to temporary file** (`*.atlas.tmp`)
|
||||
2. **Backup existing config** (`.backup`)
|
||||
3. **Atomically replace** config file
|
||||
4. **Reload service**
|
||||
5. **On failure**: Automatically restore backup
|
||||
|
||||
Example (SMB):
|
||||
```go
|
||||
// Write to temp file
|
||||
os.WriteFile(tmpPath, config, 0644)
|
||||
|
||||
// Backup existing
|
||||
cp config.conf config.conf.backup
|
||||
|
||||
// Atomic replace
|
||||
os.Rename(tmpPath, configPath)
|
||||
|
||||
// Reload service
|
||||
if err := reloadService(); err != nil {
|
||||
// Restore backup automatically
|
||||
os.Rename(backupPath, configPath)
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
## Retry Mechanisms
|
||||
|
||||
### Retry Configuration
|
||||
|
||||
The `errors.Retry` function provides configurable retry logic:
|
||||
|
||||
```go
|
||||
config := errors.DefaultRetryConfig() // 3 attempts with exponential backoff
|
||||
err := errors.Retry(func() error {
|
||||
return serviceOperation()
|
||||
}, config)
|
||||
```
|
||||
|
||||
### Default Retry Behavior
|
||||
|
||||
- **Max Attempts**: 3
|
||||
- **Backoff**: Exponential (100ms, 200ms, 400ms)
|
||||
- **Use Case**: Transient failures (network, temporary service unavailability)
|
||||
|
||||
## Error Recovery
|
||||
|
||||
### Service Configuration Recovery
|
||||
|
||||
When service configuration fails:
|
||||
|
||||
1. **Configuration is stored** (desired state preserved)
|
||||
2. **Error is logged** (for debugging)
|
||||
3. **Operation continues** (API request succeeds)
|
||||
4. **Manual retry available** (via API or automatic retry later)
|
||||
|
||||
### Database Recovery
|
||||
|
||||
- **Connection failures**: Logged and retried
|
||||
- **Transaction failures**: Rolled back automatically
|
||||
- **Schema errors**: Detected during migration
|
||||
|
||||
### ZFS Operation Recovery
|
||||
|
||||
- **Command failures**: Returned as errors to caller
|
||||
- **Partial failures**: State is preserved, operation can be retried
|
||||
- **Validation**: Performed before destructive operations
|
||||
|
||||
## Error Logging
|
||||
|
||||
All errors are logged with context:
|
||||
|
||||
```go
|
||||
log.Printf("create SMB share error: %v", err)
|
||||
log.Printf("%s service error: %v", serviceName, err)
|
||||
```
|
||||
|
||||
Error logs include:
|
||||
- Error message
|
||||
- Operation context
|
||||
- Resource identifiers
|
||||
- Timestamp (via standard log)
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Always Use Structured Errors
|
||||
|
||||
```go
|
||||
// Good
|
||||
writeError(w, errors.ErrNotFound("pool").WithDetails(poolName))
|
||||
|
||||
// Avoid
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
```
|
||||
|
||||
### 2. Handle Service Errors Gracefully
|
||||
|
||||
```go
|
||||
// Good - graceful degradation
|
||||
if err := service.Apply(); err != nil {
|
||||
log.Printf("service error (non-fatal): %v", err)
|
||||
// Continue - desired state is stored
|
||||
}
|
||||
|
||||
// Avoid - failing the request
|
||||
if err := service.Apply(); err != nil {
|
||||
return err // Don't fail the whole request
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Validate Before Operations
|
||||
|
||||
```go
|
||||
// Good - validate first
|
||||
if !datasetExists {
|
||||
writeError(w, errors.ErrNotFound("dataset"))
|
||||
return
|
||||
}
|
||||
// Then perform operation
|
||||
```
|
||||
|
||||
### 4. Use Context for Error Details
|
||||
|
||||
```go
|
||||
// Good - include context
|
||||
writeError(w, errors.ErrInternal("failed to create pool").WithDetails(err.Error()))
|
||||
|
||||
// Avoid - generic errors
|
||||
writeError(w, errors.ErrInternal("error"))
|
||||
```
|
||||
|
||||
## Error Response Format
|
||||
|
||||
All error responses follow this structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "ERROR_CODE",
|
||||
"message": "Human-readable error message",
|
||||
"details": "Additional context (optional)"
|
||||
}
|
||||
```
|
||||
|
||||
HTTP status codes match error types:
|
||||
- `400` - Bad Request / Validation Error
|
||||
- `401` - Unauthorized
|
||||
- `403` - Forbidden
|
||||
- `404` - Not Found
|
||||
- `409` - Conflict
|
||||
- `500` - Internal Error
|
||||
- `503` - Service Unavailable
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Error Tracking**: Centralized error tracking and alerting
|
||||
2. **Automatic Retry Queue**: Background retry for failed operations
|
||||
3. **Error Metrics**: Track error rates by type and endpoint
|
||||
4. **User-Friendly Messages**: More descriptive error messages
|
||||
5. **Error Correlation**: Link related errors for debugging
|
||||
297
docs/HTTPS_TLS.md
Normal file
297
docs/HTTPS_TLS.md
Normal file
@@ -0,0 +1,297 @@
|
||||
# HTTPS/TLS Support
|
||||
|
||||
## Overview
|
||||
|
||||
AtlasOS supports HTTPS/TLS encryption for secure communication. TLS can be enabled via environment variables, and the system will automatically enforce HTTPS connections when TLS is enabled.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
TLS is configured via environment variables:
|
||||
|
||||
- **`ATLAS_TLS_CERT`**: Path to TLS certificate file (PEM format)
|
||||
- **`ATLAS_TLS_KEY`**: Path to TLS private key file (PEM format)
|
||||
- **`ATLAS_TLS_ENABLED`**: Force enable TLS (optional, auto-enabled if cert/key provided)
|
||||
|
||||
### Automatic Detection
|
||||
|
||||
TLS is automatically enabled if both `ATLAS_TLS_CERT` and `ATLAS_TLS_KEY` are set:
|
||||
|
||||
```bash
|
||||
export ATLAS_TLS_CERT=/etc/atlas/tls/cert.pem
|
||||
export ATLAS_TLS_KEY=/etc/atlas/tls/key.pem
|
||||
./atlas-api
|
||||
```
|
||||
|
||||
### Explicit Enable
|
||||
|
||||
Force TLS even if cert/key are not set (will fail at startup if cert/key missing):
|
||||
|
||||
```bash
|
||||
export ATLAS_TLS_ENABLED=true
|
||||
export ATLAS_TLS_CERT=/etc/atlas/tls/cert.pem
|
||||
export ATLAS_TLS_KEY=/etc/atlas/tls/key.pem
|
||||
./atlas-api
|
||||
```
|
||||
|
||||
## Certificate Requirements
|
||||
|
||||
### Certificate Format
|
||||
|
||||
- **Format**: PEM (Privacy-Enhanced Mail)
|
||||
- **Certificate**: X.509 certificate
|
||||
- **Key**: RSA or ECDSA private key
|
||||
- **Chain**: Certificate chain can be included in cert file
|
||||
|
||||
### Certificate Validation
|
||||
|
||||
At startup, the system validates:
|
||||
- Certificate file exists
|
||||
- Key file exists
|
||||
- Certificate and key are valid and match
|
||||
- Certificate is not expired (checked by Go's TLS library)
|
||||
|
||||
## TLS Configuration
|
||||
|
||||
### Supported TLS Versions
|
||||
|
||||
- **Minimum**: TLS 1.2
|
||||
- **Maximum**: TLS 1.3
|
||||
|
||||
### Cipher Suites
|
||||
|
||||
The system uses secure cipher suites:
|
||||
|
||||
- `TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384`
|
||||
- `TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384`
|
||||
- `TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305`
|
||||
- `TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305`
|
||||
- `TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256`
|
||||
- `TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256`
|
||||
|
||||
### Elliptic Curves
|
||||
|
||||
Preferred curves:
|
||||
- `CurveP256`
|
||||
- `CurveP384`
|
||||
- `CurveP521`
|
||||
- `X25519`
|
||||
|
||||
## HTTPS Enforcement
|
||||
|
||||
### Automatic Redirect
|
||||
|
||||
When TLS is enabled, HTTP requests are automatically redirected to HTTPS:
|
||||
|
||||
```
|
||||
HTTP Request → 301 Moved Permanently → HTTPS
|
||||
```
|
||||
|
||||
### Exceptions
|
||||
|
||||
HTTPS enforcement is skipped for:
|
||||
- **Health checks**: `/healthz`, `/health` (allows monitoring)
|
||||
- **Localhost**: Requests from `localhost`, `127.0.0.1`, `::1` (development)
|
||||
|
||||
### Reverse Proxy Support
|
||||
|
||||
The system respects `X-Forwarded-Proto` header for reverse proxy setups:
|
||||
|
||||
```
|
||||
X-Forwarded-Proto: https
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Development (HTTP)
|
||||
|
||||
```bash
|
||||
# No TLS configuration - runs on HTTP
|
||||
./atlas-api
|
||||
```
|
||||
|
||||
### Production (HTTPS)
|
||||
|
||||
```bash
|
||||
# Enable TLS
|
||||
export ATLAS_TLS_CERT=/etc/ssl/certs/atlas.crt
|
||||
export ATLAS_TLS_KEY=/etc/ssl/private/atlas.key
|
||||
export ATLAS_HTTP_ADDR=:8443
|
||||
./atlas-api
|
||||
```
|
||||
|
||||
### Using Let's Encrypt
|
||||
|
||||
```bash
|
||||
# Let's Encrypt certificates
|
||||
export ATLAS_TLS_CERT=/etc/letsencrypt/live/atlas.example.com/fullchain.pem
|
||||
export ATLAS_TLS_KEY=/etc/letsencrypt/live/atlas.example.com/privkey.pem
|
||||
./atlas-api
|
||||
```
|
||||
|
||||
### Self-Signed Certificate (Testing)
|
||||
|
||||
Generate a self-signed certificate:
|
||||
|
||||
```bash
|
||||
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes
|
||||
```
|
||||
|
||||
Use it:
|
||||
|
||||
```bash
|
||||
export ATLAS_TLS_CERT=./cert.pem
|
||||
export ATLAS_TLS_KEY=./key.pem
|
||||
./atlas-api
|
||||
```
|
||||
|
||||
## Security Headers
|
||||
|
||||
When TLS is enabled, additional security headers are set:
|
||||
|
||||
### HSTS (HTTP Strict Transport Security)
|
||||
|
||||
```
|
||||
Strict-Transport-Security: max-age=31536000; includeSubDomains
|
||||
```
|
||||
|
||||
- **Max Age**: 1 year (31536000 seconds)
|
||||
- **Include Subdomains**: Yes
|
||||
- **Purpose**: Forces browsers to use HTTPS
|
||||
|
||||
### Content Security Policy
|
||||
|
||||
CSP is configured to work with HTTPS:
|
||||
|
||||
```
|
||||
Content-Security-Policy: default-src 'self'; ...
|
||||
```
|
||||
|
||||
## Reverse Proxy Setup
|
||||
|
||||
### Nginx
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name atlas.example.com;
|
||||
|
||||
ssl_certificate /etc/ssl/certs/atlas.crt;
|
||||
ssl_certificate_key /etc/ssl/private/atlas.key;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Apache
|
||||
|
||||
```apache
|
||||
<VirtualHost *:443>
|
||||
ServerName atlas.example.com
|
||||
|
||||
SSLEngine on
|
||||
SSLCertificateFile /etc/ssl/certs/atlas.crt
|
||||
SSLCertificateKeyFile /etc/ssl/private/atlas.key
|
||||
|
||||
ProxyPass / http://localhost:8080/
|
||||
ProxyPassReverse / http://localhost:8080/
|
||||
|
||||
RequestHeader set X-Forwarded-Proto "https"
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Certificate Not Found
|
||||
|
||||
```
|
||||
TLS configuration error: TLS certificate file not found: /path/to/cert.pem
|
||||
```
|
||||
|
||||
**Solution**: Verify certificate file path and permissions.
|
||||
|
||||
### Certificate/Key Mismatch
|
||||
|
||||
```
|
||||
TLS configuration error: load TLS certificate: tls: private key does not match public key
|
||||
```
|
||||
|
||||
**Solution**: Ensure certificate and key files match.
|
||||
|
||||
### Certificate Expired
|
||||
|
||||
```
|
||||
TLS handshake error: x509: certificate has expired or is not yet valid
|
||||
```
|
||||
|
||||
**Solution**: Renew certificate or use a valid certificate.
|
||||
|
||||
### Port Already in Use
|
||||
|
||||
```
|
||||
listen tcp :8443: bind: address already in use
|
||||
```
|
||||
|
||||
**Solution**: Change port or stop conflicting service.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Valid Certificates
|
||||
|
||||
- **Production**: Use certificates from trusted CAs (Let's Encrypt, commercial CAs)
|
||||
- **Development**: Self-signed certificates are acceptable
|
||||
- **Testing**: Use test certificates with short expiration
|
||||
|
||||
### 2. Certificate Renewal
|
||||
|
||||
- **Monitor Expiration**: Set up alerts for certificate expiration
|
||||
- **Auto-Renewal**: Use tools like `certbot` for Let's Encrypt
|
||||
- **Graceful Reload**: Restart service after certificate renewal
|
||||
|
||||
### 3. Key Security
|
||||
|
||||
- **Permissions**: Restrict key file permissions (`chmod 600`)
|
||||
- **Ownership**: Use dedicated user for key file
|
||||
- **Storage**: Store keys securely, never commit to version control
|
||||
|
||||
### 4. TLS Configuration
|
||||
|
||||
- **Minimum Version**: TLS 1.2 or higher
|
||||
- **Cipher Suites**: Use strong cipher suites only
|
||||
- **HSTS**: Enable HSTS for production
|
||||
|
||||
### 5. Reverse Proxy
|
||||
|
||||
- **Terminate TLS**: Terminate TLS at reverse proxy for better performance
|
||||
- **Forward Headers**: Forward `X-Forwarded-Proto` header
|
||||
- **Health Checks**: Allow HTTP for health checks
|
||||
|
||||
## Compliance
|
||||
|
||||
### SRS Requirement
|
||||
|
||||
Per SRS section 5.3 Security:
|
||||
- **HTTPS SHALL be enforced for the web UI** ✅
|
||||
|
||||
This implementation:
|
||||
- ✅ Supports TLS/HTTPS
|
||||
- ✅ Enforces HTTPS when TLS is enabled
|
||||
- ✅ Provides secure cipher suites
|
||||
- ✅ Includes HSTS headers
|
||||
- ✅ Validates certificates
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Certificate Auto-Renewal**: Automatic certificate renewal
|
||||
2. **OCSP Stapling**: Online Certificate Status Protocol stapling
|
||||
3. **Certificate Rotation**: Seamless certificate rotation
|
||||
4. **TLS 1.4 Support**: Support for future TLS versions
|
||||
5. **Client Certificate Authentication**: Mutual TLS (mTLS)
|
||||
6. **Certificate Monitoring**: Certificate expiration monitoring
|
||||
499
docs/INSTALLATION.md
Normal file
499
docs/INSTALLATION.md
Normal file
@@ -0,0 +1,499 @@
|
||||
# AtlasOS Installation Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This guide covers installing AtlasOS on a Linux system for testing and production use.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### System Requirements
|
||||
|
||||
- **OS**: Linux (Ubuntu 20.04+, Debian 11+, Fedora 34+, RHEL 8+)
|
||||
- **Kernel**: Linux kernel with ZFS support
|
||||
- **RAM**: Minimum 2GB, recommended 4GB+
|
||||
- **Disk**: Minimum 10GB free space
|
||||
- **Network**: Network interface for iSCSI/SMB/NFS
|
||||
|
||||
### Required Software
|
||||
|
||||
- ZFS utilities (`zfsutils-linux` or `zfs`)
|
||||
- Samba (`samba`)
|
||||
- NFS server (`nfs-kernel-server` or `nfs-utils`)
|
||||
- iSCSI target (`targetcli`)
|
||||
- SQLite (`sqlite3`)
|
||||
- Go compiler (`golang-go` or `golang`) - for building from source
|
||||
- Build tools (`build-essential` or `gcc make`)
|
||||
|
||||
## Quick Installation
|
||||
|
||||
### Automated Installer
|
||||
|
||||
The easiest way to install AtlasOS is using the provided installer script:
|
||||
|
||||
```bash
|
||||
# Clone or download the repository
|
||||
cd /path/to/atlas
|
||||
|
||||
# Run installer (requires root)
|
||||
sudo ./installer/install.sh
|
||||
```
|
||||
|
||||
The installer will:
|
||||
1. Install all dependencies
|
||||
2. Create system user and directories
|
||||
3. Build binaries
|
||||
4. Create systemd service
|
||||
5. Set up configuration
|
||||
6. Start the service
|
||||
|
||||
### Installation Options
|
||||
|
||||
```bash
|
||||
# Custom installation directory
|
||||
sudo ./installer/install.sh --install-dir /opt/custom-atlas
|
||||
|
||||
# Custom data directory
|
||||
sudo ./installer/install.sh --data-dir /mnt/atlas-data
|
||||
|
||||
# Skip dependency installation (if already installed)
|
||||
sudo ./installer/install.sh --skip-deps
|
||||
|
||||
# Skip building binaries (use pre-built)
|
||||
sudo ./installer/install.sh --skip-build
|
||||
|
||||
# Custom HTTP address
|
||||
sudo ./installer/install.sh --http-addr :8443
|
||||
|
||||
# Show help
|
||||
sudo ./installer/install.sh --help
|
||||
```
|
||||
|
||||
## Manual Installation
|
||||
|
||||
### Step 1: Install Dependencies
|
||||
|
||||
#### Ubuntu/Debian
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
zfsutils-linux \
|
||||
samba \
|
||||
nfs-kernel-server \
|
||||
targetcli-fb \
|
||||
sqlite3 \
|
||||
golang-go \
|
||||
git \
|
||||
build-essential
|
||||
```
|
||||
|
||||
**Note:** On newer Ubuntu/Debian versions, the iSCSI target CLI is packaged as `targetcli-fb`. If `targetcli-fb` is not available, try `targetcli`.
|
||||
|
||||
#### Fedora/RHEL/CentOS
|
||||
|
||||
```bash
|
||||
# Fedora
|
||||
sudo dnf install -y \
|
||||
zfs \
|
||||
samba \
|
||||
nfs-utils \
|
||||
targetcli \
|
||||
sqlite \
|
||||
golang \
|
||||
git \
|
||||
gcc \
|
||||
make
|
||||
|
||||
# RHEL/CentOS (with EPEL)
|
||||
sudo yum install -y epel-release
|
||||
sudo yum install -y \
|
||||
zfs \
|
||||
samba \
|
||||
nfs-utils \
|
||||
targetcli \
|
||||
sqlite \
|
||||
golang \
|
||||
git \
|
||||
gcc \
|
||||
make
|
||||
```
|
||||
|
||||
### Step 2: Load ZFS Module
|
||||
|
||||
```bash
|
||||
# Load ZFS kernel module
|
||||
sudo modprobe zfs
|
||||
|
||||
# Make it persistent
|
||||
echo "zfs" | sudo tee -a /etc/modules-load.d/zfs.conf
|
||||
```
|
||||
|
||||
### Step 3: Create System User
|
||||
|
||||
```bash
|
||||
sudo useradd -r -s /bin/false -d /var/lib/atlas atlas
|
||||
```
|
||||
|
||||
### Step 4: Create Directories
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/atlas/bin
|
||||
sudo mkdir -p /var/lib/atlas
|
||||
sudo mkdir -p /etc/atlas
|
||||
sudo mkdir -p /var/log/atlas
|
||||
sudo mkdir -p /var/lib/atlas/backups
|
||||
|
||||
sudo chown -R atlas:atlas /var/lib/atlas
|
||||
sudo chown -R atlas:atlas /var/log/atlas
|
||||
sudo chown -R atlas:atlas /etc/atlas
|
||||
```
|
||||
|
||||
### Step 5: Build Binaries
|
||||
|
||||
```bash
|
||||
cd /path/to/atlas
|
||||
go build -o /opt/atlas/bin/atlas-api ./cmd/atlas-api
|
||||
go build -o /opt/atlas/bin/atlas-tui ./cmd/atlas-tui
|
||||
|
||||
sudo chown root:root /opt/atlas/bin/atlas-api
|
||||
sudo chown root:root /opt/atlas/bin/atlas-tui
|
||||
sudo chmod 755 /opt/atlas/bin/atlas-api
|
||||
sudo chmod 755 /opt/atlas/bin/atlas-tui
|
||||
```
|
||||
|
||||
### Step 6: Create Systemd Service
|
||||
|
||||
Create `/etc/systemd/system/atlas-api.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=AtlasOS Storage Controller API
|
||||
After=network.target zfs.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=atlas
|
||||
Group=atlas
|
||||
WorkingDirectory=/opt/atlas
|
||||
ExecStart=/opt/atlas/bin/atlas-api
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=atlas-api
|
||||
|
||||
Environment="ATLAS_HTTP_ADDR=:8080"
|
||||
Environment="ATLAS_DB_PATH=/var/lib/atlas/atlas.db"
|
||||
Environment="ATLAS_BACKUP_DIR=/var/lib/atlas/backups"
|
||||
Environment="ATLAS_LOG_LEVEL=INFO"
|
||||
Environment="ATLAS_LOG_FORMAT=json"
|
||||
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/atlas /var/log/atlas /var/lib/atlas/backups /etc/atlas
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Reload systemd:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable atlas-api
|
||||
```
|
||||
|
||||
### Step 7: Configure Environment
|
||||
|
||||
Create `/etc/atlas/atlas.conf`:
|
||||
|
||||
```bash
|
||||
# HTTP Server
|
||||
ATLAS_HTTP_ADDR=:8080
|
||||
|
||||
# Database
|
||||
ATLAS_DB_PATH=/var/lib/atlas/atlas.db
|
||||
|
||||
# Backup Directory
|
||||
ATLAS_BACKUP_DIR=/var/lib/atlas/backups
|
||||
|
||||
# Logging
|
||||
ATLAS_LOG_LEVEL=INFO
|
||||
ATLAS_LOG_FORMAT=json
|
||||
|
||||
# JWT Secret (generate with: openssl rand -hex 32)
|
||||
ATLAS_JWT_SECRET=$(openssl rand -hex 32)
|
||||
```
|
||||
|
||||
### Step 8: Start Service
|
||||
|
||||
```bash
|
||||
sudo systemctl start atlas-api
|
||||
sudo systemctl status atlas-api
|
||||
```
|
||||
|
||||
## Post-Installation
|
||||
|
||||
### Create Initial Admin User
|
||||
|
||||
After installation, create the initial admin user:
|
||||
|
||||
**Via API:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/users \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"username": "admin",
|
||||
"password": "your-secure-password",
|
||||
"email": "admin@example.com",
|
||||
"role": "administrator"
|
||||
}'
|
||||
```
|
||||
|
||||
**Via TUI:**
|
||||
```bash
|
||||
/opt/atlas/bin/atlas-tui
|
||||
```
|
||||
|
||||
### Configure TLS (Optional)
|
||||
|
||||
1. Generate or obtain TLS certificates
|
||||
2. Place certificates in `/etc/atlas/tls/`:
|
||||
```bash
|
||||
sudo cp cert.pem /etc/atlas/tls/
|
||||
sudo cp key.pem /etc/atlas/tls/
|
||||
sudo chown atlas:atlas /etc/atlas/tls/*
|
||||
sudo chmod 600 /etc/atlas/tls/*
|
||||
```
|
||||
|
||||
3. Update configuration:
|
||||
```bash
|
||||
echo "ATLAS_TLS_ENABLED=true" | sudo tee -a /etc/atlas/atlas.conf
|
||||
echo "ATLAS_TLS_CERT=/etc/atlas/tls/cert.pem" | sudo tee -a /etc/atlas/atlas.conf
|
||||
echo "ATLAS_TLS_KEY=/etc/atlas/tls/key.pem" | sudo tee -a /etc/atlas/atlas.conf
|
||||
```
|
||||
|
||||
4. Restart service:
|
||||
```bash
|
||||
sudo systemctl restart atlas-api
|
||||
```
|
||||
|
||||
### Verify Installation
|
||||
|
||||
1. **Check Service Status:**
|
||||
```bash
|
||||
sudo systemctl status atlas-api
|
||||
```
|
||||
|
||||
2. **Check Logs:**
|
||||
```bash
|
||||
sudo journalctl -u atlas-api -f
|
||||
```
|
||||
|
||||
3. **Test API:**
|
||||
```bash
|
||||
curl http://localhost:8080/healthz
|
||||
```
|
||||
|
||||
4. **Access Web UI:**
|
||||
Open browser: `http://localhost:8080`
|
||||
|
||||
5. **Access API Docs:**
|
||||
Open browser: `http://localhost:8080/api/docs`
|
||||
|
||||
## Service Management
|
||||
|
||||
### Start/Stop/Restart
|
||||
|
||||
```bash
|
||||
sudo systemctl start atlas-api
|
||||
sudo systemctl stop atlas-api
|
||||
sudo systemctl restart atlas-api
|
||||
sudo systemctl status atlas-api
|
||||
```
|
||||
|
||||
### View Logs
|
||||
|
||||
```bash
|
||||
# Follow logs
|
||||
sudo journalctl -u atlas-api -f
|
||||
|
||||
# Last 100 lines
|
||||
sudo journalctl -u atlas-api -n 100
|
||||
|
||||
# Since boot
|
||||
sudo journalctl -u atlas-api -b
|
||||
```
|
||||
|
||||
### Enable/Disable Auto-Start
|
||||
|
||||
```bash
|
||||
sudo systemctl enable atlas-api # Enable on boot
|
||||
sudo systemctl disable atlas-api # Disable on boot
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Configuration is done via environment variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `ATLAS_HTTP_ADDR` | `:8080` | HTTP server address |
|
||||
| `ATLAS_DB_PATH` | `data/atlas.db` | SQLite database path |
|
||||
| `ATLAS_BACKUP_DIR` | `data/backups` | Backup directory |
|
||||
| `ATLAS_LOG_LEVEL` | `INFO` | Log level (DEBUG, INFO, WARN, ERROR) |
|
||||
| `ATLAS_LOG_FORMAT` | `text` | Log format (text, json) |
|
||||
| `ATLAS_JWT_SECRET` | - | JWT signing secret (required) |
|
||||
| `ATLAS_TLS_ENABLED` | `false` | Enable TLS |
|
||||
| `ATLAS_TLS_CERT` | - | TLS certificate file |
|
||||
| `ATLAS_TLS_KEY` | - | TLS private key file |
|
||||
|
||||
### Configuration File
|
||||
|
||||
Edit `/etc/atlas/atlas.conf` and restart service:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart atlas-api
|
||||
```
|
||||
|
||||
## Uninstallation
|
||||
|
||||
### Remove Service
|
||||
|
||||
```bash
|
||||
sudo systemctl stop atlas-api
|
||||
sudo systemctl disable atlas-api
|
||||
sudo rm /etc/systemd/system/atlas-api.service
|
||||
sudo systemctl daemon-reload
|
||||
```
|
||||
|
||||
### Remove Files
|
||||
|
||||
```bash
|
||||
sudo rm -rf /opt/atlas
|
||||
sudo rm -rf /var/lib/atlas
|
||||
sudo rm -rf /etc/atlas
|
||||
sudo rm -rf /var/log/atlas
|
||||
```
|
||||
|
||||
### Remove User
|
||||
|
||||
```bash
|
||||
sudo userdel atlas
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Service Won't Start
|
||||
|
||||
1. **Check Logs:**
|
||||
```bash
|
||||
sudo journalctl -u atlas-api -n 50
|
||||
```
|
||||
|
||||
2. **Check Permissions:**
|
||||
```bash
|
||||
ls -la /opt/atlas/bin/
|
||||
ls -la /var/lib/atlas/
|
||||
```
|
||||
|
||||
3. **Check Dependencies:**
|
||||
```bash
|
||||
which zpool
|
||||
which smbd
|
||||
which targetcli
|
||||
```
|
||||
|
||||
### Port Already in Use
|
||||
|
||||
If port 8080 is already in use:
|
||||
|
||||
```bash
|
||||
# Change port in configuration
|
||||
echo "ATLAS_HTTP_ADDR=:8443" | sudo tee -a /etc/atlas/atlas.conf
|
||||
sudo systemctl restart atlas-api
|
||||
```
|
||||
|
||||
### Database Errors
|
||||
|
||||
If database errors occur:
|
||||
|
||||
```bash
|
||||
# Check database file permissions
|
||||
ls -la /var/lib/atlas/atlas.db
|
||||
|
||||
# Fix permissions
|
||||
sudo chown atlas:atlas /var/lib/atlas/atlas.db
|
||||
sudo chmod 600 /var/lib/atlas/atlas.db
|
||||
```
|
||||
|
||||
### ZFS Not Available
|
||||
|
||||
If ZFS commands fail:
|
||||
|
||||
```bash
|
||||
# Load ZFS module
|
||||
sudo modprobe zfs
|
||||
|
||||
# Check ZFS version
|
||||
zfs --version
|
||||
|
||||
# Verify ZFS pools
|
||||
sudo zpool list
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Firewall
|
||||
|
||||
Configure firewall to allow access:
|
||||
|
||||
```bash
|
||||
# UFW (Ubuntu)
|
||||
sudo ufw allow 8080/tcp
|
||||
|
||||
# firewalld (Fedora/RHEL)
|
||||
sudo firewall-cmd --add-port=8080/tcp --permanent
|
||||
sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
### TLS/HTTPS
|
||||
|
||||
Always use HTTPS in production:
|
||||
|
||||
1. Obtain valid certificates (Let's Encrypt recommended)
|
||||
2. Configure TLS in `/etc/atlas/atlas.conf`
|
||||
3. Restart service
|
||||
|
||||
### JWT Secret
|
||||
|
||||
Generate a strong JWT secret:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
Store securely in `/etc/atlas/atlas.conf` with restricted permissions.
|
||||
|
||||
## Next Steps
|
||||
|
||||
After installation:
|
||||
|
||||
1. **Create Admin User**: Set up initial administrator account
|
||||
2. **Configure Storage**: Create ZFS pools and datasets
|
||||
3. **Set Up Services**: Configure SMB, NFS, or iSCSI shares
|
||||
4. **Enable Snapshots**: Configure snapshot policies
|
||||
5. **Review Security**: Enable TLS, configure firewall
|
||||
6. **Monitor**: Set up monitoring and alerts
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
|
||||
- Check logs: `journalctl -u atlas-api`
|
||||
- Review documentation: `docs/` directory
|
||||
- API documentation: `http://localhost:8080/api/docs`
|
||||
318
docs/ISCSI_CONNECTION.md
Normal file
318
docs/ISCSI_CONNECTION.md
Normal file
@@ -0,0 +1,318 @@
|
||||
# iSCSI Connection Instructions
|
||||
|
||||
## Overview
|
||||
|
||||
AtlasOS provides iSCSI connection instructions to help users connect initiators to iSCSI targets. The system automatically generates platform-specific commands for Linux, Windows, and macOS.
|
||||
|
||||
## API Endpoint
|
||||
|
||||
### Get Connection Instructions
|
||||
|
||||
**GET** `/api/v1/iscsi/targets/{id}/connection`
|
||||
|
||||
Returns connection instructions for an iSCSI target, including platform-specific commands.
|
||||
|
||||
**Query Parameters:**
|
||||
- `port` (optional): Portal port number (default: 3260)
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"iqn": "iqn.2024-12.com.atlas:target1",
|
||||
"portal": "192.168.1.100:3260",
|
||||
"portal_ip": "192.168.1.100",
|
||||
"portal_port": 3260,
|
||||
"luns": [
|
||||
{
|
||||
"id": 0,
|
||||
"zvol": "tank/iscsi/lun1",
|
||||
"size": 10737418240
|
||||
}
|
||||
],
|
||||
"commands": {
|
||||
"linux": [
|
||||
"# Discover target",
|
||||
"iscsiadm -m discovery -t sendtargets -p 192.168.1.100:3260",
|
||||
"",
|
||||
"# Login to target",
|
||||
"iscsiadm -m node -T iqn.2024-12.com.atlas:target1 -p 192.168.1.100:3260 --login",
|
||||
"",
|
||||
"# Verify connection",
|
||||
"iscsiadm -m session",
|
||||
"",
|
||||
"# Logout (when done)",
|
||||
"iscsiadm -m node -T iqn.2024-12.com.atlas:target1 -p 192.168.1.100:3260 --logout"
|
||||
],
|
||||
"windows": [
|
||||
"# Open PowerShell as Administrator",
|
||||
"",
|
||||
"# Add iSCSI target portal",
|
||||
"New-IscsiTargetPortal -TargetPortalAddress 192.168.1.100 -TargetPortalPortNumber 3260",
|
||||
"",
|
||||
"# Connect to target",
|
||||
"Connect-IscsiTarget -NodeAddress iqn.2024-12.com.atlas:target1",
|
||||
"",
|
||||
"# Verify connection",
|
||||
"Get-IscsiSession",
|
||||
"",
|
||||
"# Disconnect (when done)",
|
||||
"Disconnect-IscsiTarget -NodeAddress iqn.2024-12.com.atlas:target1"
|
||||
],
|
||||
"macos": [
|
||||
"# macOS uses built-in iSCSI support",
|
||||
"# Use System Preferences > Network > iSCSI",
|
||||
"",
|
||||
"# Or use command line (if iscsiutil is available)",
|
||||
"iscsiutil -a -t iqn.2024-12.com.atlas:target1 -p 192.168.1.100:3260",
|
||||
"",
|
||||
"# Portal: 192.168.1.100:3260",
|
||||
"# Target IQN: iqn.2024-12.com.atlas:target1"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Get Connection Instructions
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/api/v1/iscsi/targets/iscsi-1/connection \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
### With Custom Port
|
||||
|
||||
```bash
|
||||
curl "http://localhost:8080/api/v1/iscsi/targets/iscsi-1/connection?port=3261" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
## Platform-Specific Instructions
|
||||
|
||||
### Linux
|
||||
|
||||
**Prerequisites:**
|
||||
- `open-iscsi` package installed
|
||||
- `iscsid` service running
|
||||
|
||||
**Steps:**
|
||||
1. Discover the target
|
||||
2. Login to the target
|
||||
3. Verify connection
|
||||
4. Use the device (appears as `/dev/sdX` or `/dev/disk/by-id/...`)
|
||||
5. Logout when done
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
# Discover target
|
||||
iscsiadm -m discovery -t sendtargets -p 192.168.1.100:3260
|
||||
|
||||
# Login to target
|
||||
iscsiadm -m node -T iqn.2024-12.com.atlas:target1 -p 192.168.1.100:3260 --login
|
||||
|
||||
# Verify connection
|
||||
iscsiadm -m session
|
||||
|
||||
# Find device
|
||||
lsblk
|
||||
# or
|
||||
ls -l /dev/disk/by-id/ | grep iqn
|
||||
|
||||
# Logout when done
|
||||
iscsiadm -m node -T iqn.2024-12.com.atlas:target1 -p 192.168.1.100:3260 --logout
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
**Prerequisites:**
|
||||
- Windows 8+ or Windows Server 2012+
|
||||
- PowerShell (run as Administrator)
|
||||
|
||||
**Steps:**
|
||||
1. Add iSCSI target portal
|
||||
2. Connect to target
|
||||
3. Verify connection
|
||||
4. Initialize disk in Disk Management
|
||||
5. Disconnect when done
|
||||
|
||||
**Example (PowerShell as Administrator):**
|
||||
```powershell
|
||||
# Add portal
|
||||
New-IscsiTargetPortal -TargetPortalAddress 192.168.1.100 -TargetPortalPortNumber 3260
|
||||
|
||||
# Connect to target
|
||||
Connect-IscsiTarget -NodeAddress iqn.2024-12.com.atlas:target1
|
||||
|
||||
# Verify connection
|
||||
Get-IscsiSession
|
||||
|
||||
# Initialize disk in Disk Management
|
||||
# (Open Disk Management, find new disk, initialize and format)
|
||||
|
||||
# Disconnect when done
|
||||
Disconnect-IscsiTarget -NodeAddress iqn.2024-12.com.atlas:target1
|
||||
```
|
||||
|
||||
### macOS
|
||||
|
||||
**Prerequisites:**
|
||||
- macOS 10.13+ (High Sierra or later)
|
||||
- iSCSI initiator software (third-party)
|
||||
|
||||
**Steps:**
|
||||
1. Use GUI iSCSI initiator (if available)
|
||||
2. Or use command line tools
|
||||
3. Configure connection settings
|
||||
4. Connect to target
|
||||
|
||||
**Note:** macOS doesn't have built-in iSCSI support. Use third-party software like:
|
||||
- GlobalSAN iSCSI Initiator
|
||||
- ATTO Xtend SAN iSCSI
|
||||
|
||||
## Portal IP Detection
|
||||
|
||||
The system automatically detects the portal IP address using:
|
||||
|
||||
1. **Primary Method**: Parse `targetcli` output to find configured portal IP
|
||||
2. **Fallback Method**: Use system IP from `hostname -I`
|
||||
3. **Default**: `127.0.0.1` if detection fails
|
||||
|
||||
**Custom Portal IP:**
|
||||
|
||||
If the detected IP is incorrect, you can manually specify it by:
|
||||
- Setting environment variable `ATLAS_ISCSI_PORTAL_IP`
|
||||
- Or modifying the connection instructions after retrieval
|
||||
|
||||
## LUN Information
|
||||
|
||||
The connection instructions include LUN information:
|
||||
|
||||
- **ID**: LUN number (typically 0, 1, 2, ...)
|
||||
- **ZVOL**: ZFS volume backing the LUN
|
||||
- **Size**: LUN size in bytes
|
||||
|
||||
**Example:**
|
||||
```json
|
||||
"luns": [
|
||||
{
|
||||
"id": 0,
|
||||
"zvol": "tank/iscsi/lun1",
|
||||
"size": 10737418240
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"zvol": "tank/iscsi/lun2",
|
||||
"size": 21474836480
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Initiator ACLs
|
||||
|
||||
iSCSI targets can be configured with initiator ACLs to restrict access:
|
||||
|
||||
```json
|
||||
{
|
||||
"iqn": "iqn.2024-12.com.atlas:target1",
|
||||
"initiators": [
|
||||
"iqn.2024-12.com.client:initiator1"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Only initiators in the ACL list can connect to the target.
|
||||
|
||||
### CHAP Authentication
|
||||
|
||||
For production deployments, configure CHAP authentication:
|
||||
|
||||
1. Set up CHAP credentials in target configuration
|
||||
2. Configure initiator with matching credentials
|
||||
3. Use authentication in connection commands
|
||||
|
||||
**Note:** CHAP configuration is not yet exposed via API (future enhancement).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection Fails
|
||||
|
||||
1. **Check Target Status**: Verify target is enabled
|
||||
2. **Check Portal**: Verify portal IP and port are correct
|
||||
3. **Check Network**: Ensure network connectivity
|
||||
4. **Check ACLs**: Verify initiator IQN is in ACL list
|
||||
5. **Check Firewall**: Ensure port 3260 (or custom port) is open
|
||||
|
||||
### Portal IP Incorrect
|
||||
|
||||
If the detected portal IP is wrong:
|
||||
|
||||
1. Check `targetcli` configuration
|
||||
2. Verify network interfaces
|
||||
3. Manually override in connection commands
|
||||
|
||||
### LUN Not Visible
|
||||
|
||||
1. **Check LUN Mapping**: Verify LUN is mapped to target
|
||||
2. **Check ZVOL**: Verify ZVOL exists and is accessible
|
||||
3. **Rescan**: Rescan iSCSI session on initiator
|
||||
4. **Check Permissions**: Verify initiator has access
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use ACLs
|
||||
|
||||
Always configure initiator ACLs to restrict access:
|
||||
- Only allow known initiators
|
||||
- Use descriptive initiator IQNs
|
||||
- Regularly review ACL lists
|
||||
|
||||
### 2. Use CHAP Authentication
|
||||
|
||||
For production:
|
||||
- Enable CHAP authentication
|
||||
- Use strong credentials
|
||||
- Rotate credentials regularly
|
||||
|
||||
### 3. Monitor Connections
|
||||
|
||||
- Monitor active iSCSI sessions
|
||||
- Track connection/disconnection events
|
||||
- Set up alerts for connection failures
|
||||
|
||||
### 4. Test Connections
|
||||
|
||||
Before production use:
|
||||
- Test connection from initiator
|
||||
- Verify LUN visibility
|
||||
- Test read/write operations
|
||||
- Test disconnection/reconnection
|
||||
|
||||
### 5. Document Configuration
|
||||
|
||||
- Document portal IPs and ports
|
||||
- Document initiator IQNs
|
||||
- Document LUN mappings
|
||||
- Keep connection instructions accessible
|
||||
|
||||
## Compliance with SRS
|
||||
|
||||
Per SRS section 4.6 iSCSI Block Storage:
|
||||
|
||||
- ✅ **Provision ZVOL-backed LUNs**: Implemented
|
||||
- ✅ **Create iSCSI targets with IQN**: Implemented
|
||||
- ✅ **Map LUNs to targets**: Implemented
|
||||
- ✅ **Configure initiator ACLs**: Implemented
|
||||
- ✅ **Expose connection instructions**: Implemented (Priority 21)
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **CHAP Authentication**: API support for CHAP configuration
|
||||
2. **Portal Management**: Manage multiple portals per target
|
||||
3. **Connection Monitoring**: Real-time connection status
|
||||
4. **Auto-Discovery**: Automatic initiator discovery
|
||||
5. **Connection Templates**: Pre-configured connection templates
|
||||
6. **Connection History**: Track connection/disconnection events
|
||||
7. **Multi-Path Support**: Instructions for multi-path configurations
|
||||
366
docs/LOGGING_DIAGNOSTICS.md
Normal file
366
docs/LOGGING_DIAGNOSTICS.md
Normal file
@@ -0,0 +1,366 @@
|
||||
# Logging & Diagnostics
|
||||
|
||||
## Overview
|
||||
|
||||
AtlasOS provides comprehensive logging and diagnostic capabilities to help monitor system health, troubleshoot issues, and understand system behavior.
|
||||
|
||||
## Structured Logging
|
||||
|
||||
### Logger Package
|
||||
|
||||
The `internal/logger` package provides structured logging with:
|
||||
|
||||
- **Log Levels**: DEBUG, INFO, WARN, ERROR
|
||||
- **JSON Mode**: Optional JSON-formatted output
|
||||
- **Structured Fields**: Key-value pairs for context
|
||||
- **Thread-Safe**: Safe for concurrent use
|
||||
|
||||
### Configuration
|
||||
|
||||
Configure logging via environment variables:
|
||||
|
||||
```bash
|
||||
# Log level (DEBUG, INFO, WARN, ERROR)
|
||||
export ATLAS_LOG_LEVEL=INFO
|
||||
|
||||
# Log format (json or text)
|
||||
export ATLAS_LOG_FORMAT=json
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```go
|
||||
import "gitea.avt.data-center.id/othman.suseno/atlas/internal/logger"
|
||||
|
||||
// Simple logging
|
||||
logger.Info("User logged in")
|
||||
logger.Error("Failed to create pool", err)
|
||||
|
||||
// With fields
|
||||
logger.Info("Pool created", map[string]interface{}{
|
||||
"pool": "tank",
|
||||
"size": "10TB",
|
||||
})
|
||||
```
|
||||
|
||||
### Log Levels
|
||||
|
||||
- **DEBUG**: Detailed information for debugging
|
||||
- **INFO**: General informational messages
|
||||
- **WARN**: Warning messages for potential issues
|
||||
- **ERROR**: Error messages for failures
|
||||
|
||||
## Request Logging
|
||||
|
||||
### Access Logs
|
||||
|
||||
All HTTP requests are logged with:
|
||||
|
||||
- **Timestamp**: Request time
|
||||
- **Method**: HTTP method (GET, POST, etc.)
|
||||
- **Path**: Request path
|
||||
- **Status**: HTTP status code
|
||||
- **Duration**: Request processing time
|
||||
- **Request ID**: Unique request identifier
|
||||
- **Remote Address**: Client IP address
|
||||
|
||||
**Example Log Entry:**
|
||||
```
|
||||
2024-12-20T10:30:56Z [INFO] 192.168.1.100 GET /api/v1/pools status=200 rid=abc123 dur=45ms
|
||||
```
|
||||
|
||||
### Request ID
|
||||
|
||||
Every request gets a unique request ID:
|
||||
|
||||
- **Header**: `X-Request-Id`
|
||||
- **Usage**: Track requests across services
|
||||
- **Format**: 32-character hex string
|
||||
|
||||
## Diagnostic Endpoints
|
||||
|
||||
### System Information
|
||||
|
||||
**GET** `/api/v1/system/info`
|
||||
|
||||
Returns comprehensive system information:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "v0.1.0-dev",
|
||||
"uptime": "3600 seconds",
|
||||
"go_version": "go1.21.0",
|
||||
"num_goroutines": 15,
|
||||
"memory": {
|
||||
"alloc": 1048576,
|
||||
"total_alloc": 52428800,
|
||||
"sys": 2097152,
|
||||
"num_gc": 5
|
||||
},
|
||||
"services": {
|
||||
"smb": {
|
||||
"status": "running",
|
||||
"last_check": "2024-12-20T10:30:56Z"
|
||||
},
|
||||
"nfs": {
|
||||
"status": "running",
|
||||
"last_check": "2024-12-20T10:30:56Z"
|
||||
},
|
||||
"iscsi": {
|
||||
"status": "stopped",
|
||||
"last_check": "2024-12-20T10:30:56Z"
|
||||
}
|
||||
},
|
||||
"database": {
|
||||
"connected": true,
|
||||
"path": "/var/lib/atlas/atlas.db"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Health Check
|
||||
|
||||
**GET** `/health`
|
||||
|
||||
Detailed health check with component status:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"timestamp": "2024-12-20T10:30:56Z",
|
||||
"checks": {
|
||||
"zfs": "healthy",
|
||||
"database": "healthy",
|
||||
"smb": "healthy",
|
||||
"nfs": "healthy",
|
||||
"iscsi": "stopped"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status Values:**
|
||||
- `healthy`: Component is working correctly
|
||||
- `degraded`: Some components have issues but system is operational
|
||||
- `unhealthy`: Critical components are failing
|
||||
|
||||
**HTTP Status Codes:**
|
||||
- `200 OK`: System is healthy or degraded
|
||||
- `503 Service Unavailable`: System is unhealthy
|
||||
|
||||
### System Logs
|
||||
|
||||
**GET** `/api/v1/system/logs?limit=100`
|
||||
|
||||
Returns recent system logs (from audit logs):
|
||||
|
||||
```json
|
||||
{
|
||||
"logs": [
|
||||
{
|
||||
"timestamp": "2024-12-20T10:30:56Z",
|
||||
"level": "INFO",
|
||||
"actor": "user-1",
|
||||
"action": "pool.create",
|
||||
"resource": "pool:tank",
|
||||
"result": "success",
|
||||
"ip": "192.168.1.100"
|
||||
}
|
||||
],
|
||||
"count": 1
|
||||
}
|
||||
```
|
||||
|
||||
**Query Parameters:**
|
||||
- `limit`: Maximum number of logs to return (default: 100, max: 1000)
|
||||
|
||||
### Garbage Collection
|
||||
|
||||
**POST** `/api/v1/system/gc`
|
||||
|
||||
Triggers garbage collection and returns memory statistics:
|
||||
|
||||
```json
|
||||
{
|
||||
"before": {
|
||||
"alloc": 1048576,
|
||||
"total_alloc": 52428800,
|
||||
"sys": 2097152,
|
||||
"num_gc": 5
|
||||
},
|
||||
"after": {
|
||||
"alloc": 512000,
|
||||
"total_alloc": 52428800,
|
||||
"sys": 2097152,
|
||||
"num_gc": 6
|
||||
},
|
||||
"freed": 536576
|
||||
}
|
||||
```
|
||||
|
||||
## Audit Logging
|
||||
|
||||
Audit logs track all mutating operations:
|
||||
|
||||
- **Actor**: User ID or "system"
|
||||
- **Action**: Operation type (e.g., "pool.create")
|
||||
- **Resource**: Resource identifier
|
||||
- **Result**: "success" or "failure"
|
||||
- **IP**: Client IP address
|
||||
- **User Agent**: Client user agent
|
||||
- **Timestamp**: Operation time
|
||||
|
||||
See [Audit Logging Documentation](./AUDIT_LOGGING.md) for details.
|
||||
|
||||
## Log Rotation
|
||||
|
||||
### Current Implementation
|
||||
|
||||
- **In-Memory**: Audit logs stored in memory
|
||||
- **Rotation**: Automatic rotation when max logs reached
|
||||
- **Limit**: Configurable (default: 10,000 logs)
|
||||
|
||||
### Future Enhancements
|
||||
|
||||
- **File Logging**: Write logs to files
|
||||
- **Automatic Rotation**: Rotate log files by size/age
|
||||
- **Compression**: Compress old log files
|
||||
- **Retention**: Configurable retention policies
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Appropriate Log Levels
|
||||
|
||||
```go
|
||||
// Debug - detailed information
|
||||
logger.Debug("Processing request", map[string]interface{}{
|
||||
"request_id": reqID,
|
||||
"user": userID,
|
||||
})
|
||||
|
||||
// Info - important events
|
||||
logger.Info("User logged in", map[string]interface{}{
|
||||
"user": userID,
|
||||
})
|
||||
|
||||
// Warn - potential issues
|
||||
logger.Warn("High memory usage", map[string]interface{}{
|
||||
"usage": "85%",
|
||||
})
|
||||
|
||||
// Error - failures
|
||||
logger.Error("Failed to create pool", err, map[string]interface{}{
|
||||
"pool": poolName,
|
||||
})
|
||||
```
|
||||
|
||||
### 2. Include Context
|
||||
|
||||
Always include relevant context in logs:
|
||||
|
||||
```go
|
||||
// Good
|
||||
logger.Info("Pool created", map[string]interface{}{
|
||||
"pool": poolName,
|
||||
"size": poolSize,
|
||||
"user": userID,
|
||||
})
|
||||
|
||||
// Avoid
|
||||
logger.Info("Pool created")
|
||||
```
|
||||
|
||||
### 3. Use Request IDs
|
||||
|
||||
Include request IDs in logs for tracing:
|
||||
|
||||
```go
|
||||
reqID := r.Context().Value(requestIDKey).(string)
|
||||
logger.Info("Processing request", map[string]interface{}{
|
||||
"request_id": reqID,
|
||||
})
|
||||
```
|
||||
|
||||
### 4. Monitor Health Endpoints
|
||||
|
||||
Regularly check health endpoints:
|
||||
|
||||
```bash
|
||||
# Simple health check
|
||||
curl http://localhost:8080/healthz
|
||||
|
||||
# Detailed health check
|
||||
curl http://localhost:8080/health
|
||||
|
||||
# System information
|
||||
curl http://localhost:8080/api/v1/system/info
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Key Metrics
|
||||
|
||||
Monitor these metrics for system health:
|
||||
|
||||
- **Request Duration**: Track in access logs
|
||||
- **Error Rate**: Count of error responses
|
||||
- **Memory Usage**: Check via `/api/v1/system/info`
|
||||
- **Goroutine Count**: Monitor for leaks
|
||||
- **Service Status**: Check service health
|
||||
|
||||
### Alerting
|
||||
|
||||
Set up alerts for:
|
||||
|
||||
- **Unhealthy Status**: System health check fails
|
||||
- **High Error Rate**: Too many error responses
|
||||
- **Memory Leaks**: Continuously increasing memory
|
||||
- **Service Failures**: Services not running
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Check System Health
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/health
|
||||
```
|
||||
|
||||
### View System Information
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/api/v1/system/info
|
||||
```
|
||||
|
||||
### Check Recent Logs
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/api/v1/system/logs?limit=50
|
||||
```
|
||||
|
||||
### Trigger GC
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/system/gc
|
||||
```
|
||||
|
||||
### View Request Logs
|
||||
|
||||
Check application logs for request details:
|
||||
|
||||
```bash
|
||||
# If logging to stdout
|
||||
./atlas-api | grep "GET /api/v1/pools"
|
||||
|
||||
# If logging to file
|
||||
tail -f /var/log/atlas-api.log | grep "status=500"
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **File Logging**: Write logs to files with rotation
|
||||
2. **Log Aggregation**: Support for centralized logging (ELK, Loki)
|
||||
3. **Structured Logging**: Full JSON logging support
|
||||
4. **Log Levels per Component**: Different levels for different components
|
||||
5. **Performance Logging**: Detailed performance metrics
|
||||
6. **Distributed Tracing**: Request tracing across services
|
||||
7. **Log Filtering**: Filter logs by level, component, etc.
|
||||
8. **Real-time Log Streaming**: Stream logs via WebSocket
|
||||
303
docs/MAINTENANCE_MODE.md
Normal file
303
docs/MAINTENANCE_MODE.md
Normal file
@@ -0,0 +1,303 @@
|
||||
# Maintenance Mode & Update Management
|
||||
|
||||
## Overview
|
||||
|
||||
AtlasOS provides a maintenance mode feature that allows administrators to safely disable user operations during system updates or maintenance. When maintenance mode is enabled, all mutating operations (create, update, delete) are blocked except for users explicitly allowed.
|
||||
|
||||
## Features
|
||||
|
||||
- **Maintenance Mode**: Disable user operations during maintenance
|
||||
- **Automatic Backup**: Optionally create backup before entering maintenance
|
||||
- **Allowed Users**: Specify users who can operate during maintenance
|
||||
- **Health Check Integration**: Maintenance status included in health checks
|
||||
- **Audit Logging**: All maintenance mode changes are logged
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Get Maintenance Status
|
||||
|
||||
**GET** `/api/v1/maintenance`
|
||||
|
||||
Returns the current maintenance mode status.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"enabled": false,
|
||||
"enabled_at": "2024-12-20T10:30:00Z",
|
||||
"enabled_by": "admin",
|
||||
"reason": "System update",
|
||||
"allowed_users": ["admin"],
|
||||
"last_backup_id": "backup-1703123456"
|
||||
}
|
||||
```
|
||||
|
||||
### Enable Maintenance Mode
|
||||
|
||||
**POST** `/api/v1/maintenance`
|
||||
|
||||
Enables maintenance mode. Requires administrator role.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"reason": "System update to v1.1.0",
|
||||
"allowed_users": ["admin"],
|
||||
"create_backup": true
|
||||
}
|
||||
```
|
||||
|
||||
**Fields:**
|
||||
- `reason` (string, required): Reason for entering maintenance mode
|
||||
- `allowed_users` (array of strings, optional): User IDs allowed to operate during maintenance
|
||||
- `create_backup` (boolean, optional): Create automatic backup before entering maintenance
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"message": "maintenance mode enabled",
|
||||
"status": {
|
||||
"enabled": true,
|
||||
"enabled_at": "2024-12-20T10:30:00Z",
|
||||
"enabled_by": "admin",
|
||||
"reason": "System update to v1.1.0",
|
||||
"allowed_users": ["admin"],
|
||||
"last_backup_id": "backup-1703123456"
|
||||
},
|
||||
"backup_id": "backup-1703123456"
|
||||
}
|
||||
```
|
||||
|
||||
### Disable Maintenance Mode
|
||||
|
||||
**POST** `/api/v1/maintenance/disable`
|
||||
|
||||
Disables maintenance mode. Requires administrator role.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"message": "maintenance mode disabled"
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Enable Maintenance Mode with Backup
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/maintenance \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"reason": "System update to v1.1.0",
|
||||
"allowed_users": ["admin"],
|
||||
"create_backup": true
|
||||
}'
|
||||
```
|
||||
|
||||
### Check Maintenance Status
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/api/v1/maintenance \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
### Disable Maintenance Mode
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/maintenance/disable \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
## Behavior
|
||||
|
||||
### When Maintenance Mode is Enabled
|
||||
|
||||
1. **Read Operations**: All GET requests continue to work normally
|
||||
2. **Mutating Operations**: All POST, PUT, PATCH, DELETE requests are blocked
|
||||
3. **Allowed Users**: Users in the `allowed_users` list can still perform operations
|
||||
4. **Public Endpoints**: Public endpoints (login, health checks) continue to work
|
||||
5. **Error Response**: Blocked operations return `503 Service Unavailable` with message:
|
||||
```json
|
||||
{
|
||||
"code": "SERVICE_UNAVAILABLE",
|
||||
"message": "system is in maintenance mode",
|
||||
"details": "the system is currently in maintenance mode and user operations are disabled"
|
||||
}
|
||||
```
|
||||
|
||||
### Middleware Order
|
||||
|
||||
Maintenance mode middleware is applied after authentication but before routes:
|
||||
|
||||
1. CORS
|
||||
2. Compression
|
||||
3. Security headers
|
||||
4. Request size limit
|
||||
5. Content-Type validation
|
||||
6. Rate limiting
|
||||
7. Caching
|
||||
8. Error recovery
|
||||
9. Request ID
|
||||
10. Logging
|
||||
11. Audit
|
||||
12. **Maintenance mode** ← Blocks operations
|
||||
13. Authentication
|
||||
14. Routes
|
||||
|
||||
## Health Check Integration
|
||||
|
||||
The health check endpoint (`/health`) includes maintenance mode status:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "maintenance",
|
||||
"timestamp": "2024-12-20T10:30:00Z",
|
||||
"checks": {
|
||||
"zfs": "healthy",
|
||||
"database": "healthy",
|
||||
"smb": "healthy",
|
||||
"nfs": "healthy",
|
||||
"iscsi": "healthy",
|
||||
"maintenance": "enabled"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When maintenance mode is enabled:
|
||||
- Status may change from "healthy" to "maintenance"
|
||||
- `checks.maintenance` will be "enabled"
|
||||
|
||||
## Automatic Backup
|
||||
|
||||
When `create_backup: true` is specified:
|
||||
|
||||
1. A backup is created automatically before entering maintenance
|
||||
2. The backup ID is stored in maintenance status
|
||||
3. The backup includes:
|
||||
- All user accounts
|
||||
- All SMB shares
|
||||
- All NFS exports
|
||||
- All iSCSI targets
|
||||
- All snapshot policies
|
||||
- System configuration
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Before System Updates
|
||||
|
||||
1. **Create Backup**: Always enable `create_backup: true`
|
||||
2. **Notify Users**: Inform users about maintenance window
|
||||
3. **Allow Administrators**: Include admin users in `allowed_users`
|
||||
4. **Document Reason**: Provide clear reason for maintenance
|
||||
|
||||
### During Maintenance
|
||||
|
||||
1. **Monitor Status**: Check `/api/v1/maintenance` periodically
|
||||
2. **Verify Backup**: Confirm backup was created successfully
|
||||
3. **Perform Updates**: Execute system updates or maintenance tasks
|
||||
4. **Test Operations**: Verify system functionality
|
||||
|
||||
### After Maintenance
|
||||
|
||||
1. **Disable Maintenance**: Use `/api/v1/maintenance/disable`
|
||||
2. **Verify Services**: Check all services are running
|
||||
3. **Test Operations**: Verify normal operations work
|
||||
4. **Review Logs**: Check audit logs for any issues
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Administrator Only**: Only administrators can enable/disable maintenance mode
|
||||
2. **Audit Logging**: All maintenance mode changes are logged
|
||||
3. **Allowed Users**: Only specified users can operate during maintenance
|
||||
4. **Token Validation**: Maintenance mode respects authentication
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Maintenance Mode Already Enabled
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "INTERNAL_ERROR",
|
||||
"message": "failed to enable maintenance mode",
|
||||
"details": "maintenance mode is already enabled"
|
||||
}
|
||||
```
|
||||
|
||||
### Maintenance Mode Not Enabled
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "INTERNAL_ERROR",
|
||||
"message": "failed to disable maintenance mode",
|
||||
"details": "maintenance mode is not enabled"
|
||||
}
|
||||
```
|
||||
|
||||
### Backup Creation Failure
|
||||
|
||||
If backup creation fails, maintenance mode is not enabled:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "INTERNAL_ERROR",
|
||||
"message": "failed to create backup",
|
||||
"details": "error details..."
|
||||
}
|
||||
```
|
||||
|
||||
## Integration with Update Process
|
||||
|
||||
### Recommended Update Workflow
|
||||
|
||||
1. **Enable Maintenance Mode**:
|
||||
```bash
|
||||
POST /api/v1/maintenance
|
||||
{
|
||||
"reason": "Updating to v1.1.0",
|
||||
"allowed_users": ["admin"],
|
||||
"create_backup": true
|
||||
}
|
||||
```
|
||||
|
||||
2. **Verify Backup**:
|
||||
```bash
|
||||
GET /api/v1/backups/{backup_id}
|
||||
```
|
||||
|
||||
3. **Perform System Update**:
|
||||
- Stop services if needed
|
||||
- Update binaries/configurations
|
||||
- Restart services
|
||||
|
||||
4. **Verify System Health**:
|
||||
```bash
|
||||
GET /health
|
||||
```
|
||||
|
||||
5. **Disable Maintenance Mode**:
|
||||
```bash
|
||||
POST /api/v1/maintenance/disable
|
||||
```
|
||||
|
||||
6. **Test Operations**:
|
||||
- Verify normal operations work
|
||||
- Check service status
|
||||
- Review logs
|
||||
|
||||
## Limitations
|
||||
|
||||
1. **No Automatic Disable**: Maintenance mode must be manually disabled
|
||||
2. **No Scheduled Maintenance**: Maintenance mode must be enabled manually
|
||||
3. **No Maintenance History**: Only current status is available
|
||||
4. **No Notifications**: No automatic notifications to users
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Scheduled Maintenance**: Schedule maintenance windows
|
||||
2. **Maintenance History**: Track maintenance mode history
|
||||
3. **User Notifications**: Notify users when maintenance starts/ends
|
||||
4. **Automatic Disable**: Auto-disable after specified duration
|
||||
5. **Maintenance Templates**: Predefined maintenance scenarios
|
||||
6. **Rollback Support**: Automatic rollback on update failure
|
||||
296
docs/PERFORMANCE_OPTIMIZATION.md
Normal file
296
docs/PERFORMANCE_OPTIMIZATION.md
Normal file
@@ -0,0 +1,296 @@
|
||||
# Performance Optimization
|
||||
|
||||
## Overview
|
||||
|
||||
AtlasOS implements several performance optimizations to improve response times, reduce bandwidth usage, and enhance overall system efficiency.
|
||||
|
||||
## Compression
|
||||
|
||||
### Gzip Compression Middleware
|
||||
|
||||
All HTTP responses are automatically compressed using gzip when the client supports it.
|
||||
|
||||
**Features:**
|
||||
- **Automatic Detection**: Checks `Accept-Encoding` header
|
||||
- **Content-Type Filtering**: Skips compression for already-compressed content (images, videos, zip files)
|
||||
- **Transparent**: Works automatically for all responses
|
||||
|
||||
**Benefits:**
|
||||
- Reduces bandwidth usage by 60-80% for JSON/text responses
|
||||
- Faster response times, especially for large payloads
|
||||
- Lower server load
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
# Request with compression
|
||||
curl -H "Accept-Encoding: gzip" http://localhost:8080/api/v1/pools
|
||||
|
||||
# Response includes:
|
||||
# Content-Encoding: gzip
|
||||
# Vary: Accept-Encoding
|
||||
```
|
||||
|
||||
## Response Caching
|
||||
|
||||
### HTTP Response Cache
|
||||
|
||||
GET requests are cached to reduce database and computation overhead.
|
||||
|
||||
**Features:**
|
||||
- **TTL-Based**: 5-minute default cache lifetime
|
||||
- **ETag Support**: HTTP ETag validation for conditional requests
|
||||
- **Automatic Cleanup**: Expired entries removed automatically
|
||||
- **Cache Headers**: `X-Cache: HIT/MISS` header indicates cache status
|
||||
|
||||
**Cache Key Generation:**
|
||||
- Includes HTTP method, path, and query string
|
||||
- SHA256 hash for consistent key length
|
||||
- Per-request unique keys
|
||||
|
||||
**Cached Endpoints:**
|
||||
- Public GET endpoints (pools, datasets, ZVOLs lists)
|
||||
- Static resources
|
||||
- Read-only operations
|
||||
|
||||
**Non-Cached Endpoints:**
|
||||
- Authenticated endpoints (user-specific data)
|
||||
- Dynamic endpoints (`/metrics`, `/health`, `/dashboard`)
|
||||
- Mutating operations (POST, PUT, DELETE)
|
||||
|
||||
**ETag Support:**
|
||||
```bash
|
||||
# First request
|
||||
curl http://localhost:8080/api/v1/pools
|
||||
# Response: ETag: "abc123..." X-Cache: MISS
|
||||
|
||||
# Conditional request
|
||||
curl -H "If-None-Match: \"abc123...\"" http://localhost:8080/api/v1/pools
|
||||
# Response: 304 Not Modified (no body)
|
||||
```
|
||||
|
||||
**Cache Invalidation:**
|
||||
- Automatic expiration after TTL
|
||||
- Manual invalidation via cache API (future enhancement)
|
||||
- Pattern-based invalidation support
|
||||
|
||||
## Database Connection Pooling
|
||||
|
||||
### Optimized Connection Pool
|
||||
|
||||
SQLite database connections are pooled for better performance.
|
||||
|
||||
**Configuration:**
|
||||
```go
|
||||
conn.SetMaxOpenConns(25) // Maximum open connections
|
||||
conn.SetMaxIdleConns(5) // Maximum idle connections
|
||||
conn.SetConnMaxLifetime(5 * time.Minute) // Connection lifetime
|
||||
```
|
||||
|
||||
**WAL Mode:**
|
||||
- Write-Ahead Logging enabled for better concurrency
|
||||
- Improved read performance
|
||||
- Better handling of concurrent readers
|
||||
|
||||
**Benefits:**
|
||||
- Reduced connection overhead
|
||||
- Better resource utilization
|
||||
- Improved concurrent request handling
|
||||
|
||||
## Middleware Chain Optimization
|
||||
|
||||
### Efficient Middleware Order
|
||||
|
||||
Middleware is ordered for optimal performance:
|
||||
|
||||
1. **CORS** - Early exit for preflight
|
||||
2. **Compression** - Compress responses early
|
||||
3. **Security Headers** - Add headers once
|
||||
4. **Request Size Limit** - Reject large requests early
|
||||
5. **Content-Type Validation** - Validate early
|
||||
6. **Rate Limiting** - Protect resources
|
||||
7. **Caching** - Return cached responses quickly
|
||||
8. **Error Recovery** - Catch panics
|
||||
9. **Request ID** - Generate ID once
|
||||
10. **Logging** - Log after processing
|
||||
11. **Audit** - Record after success
|
||||
12. **Authentication** - Validate last (after cache check)
|
||||
|
||||
**Performance Impact:**
|
||||
- Cached responses skip most middleware
|
||||
- Early validation prevents unnecessary processing
|
||||
- Compression reduces bandwidth
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Caching Effectively
|
||||
|
||||
```bash
|
||||
# Cache-friendly requests
|
||||
GET /api/v1/pools # Cached
|
||||
GET /api/v1/datasets # Cached
|
||||
|
||||
# Non-cached (dynamic)
|
||||
GET /api/v1/dashboard # Not cached (real-time data)
|
||||
GET /api/v1/system/info # Not cached (system state)
|
||||
```
|
||||
|
||||
### 2. Leverage ETags
|
||||
|
||||
```bash
|
||||
# Check if content changed
|
||||
curl -H "If-None-Match: \"etag-value\"" /api/v1/pools
|
||||
|
||||
# Server responds with 304 if unchanged
|
||||
```
|
||||
|
||||
### 3. Enable Compression
|
||||
|
||||
```bash
|
||||
# Always include Accept-Encoding header
|
||||
curl -H "Accept-Encoding: gzip" /api/v1/pools
|
||||
```
|
||||
|
||||
### 4. Monitor Cache Performance
|
||||
|
||||
Check `X-Cache` header:
|
||||
- `HIT`: Response served from cache
|
||||
- `MISS`: Response generated fresh
|
||||
|
||||
### 5. Database Queries
|
||||
|
||||
- Use connection pooling (automatic)
|
||||
- WAL mode enabled for better concurrency
|
||||
- Connection lifetime managed automatically
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
### Response Times
|
||||
|
||||
Monitor response times via:
|
||||
- Access logs (duration in logs)
|
||||
- `/metrics` endpoint (Prometheus metrics)
|
||||
- Request ID tracking
|
||||
|
||||
### Cache Hit Rate
|
||||
|
||||
Monitor cache effectiveness:
|
||||
- Check `X-Cache: HIT` vs `X-Cache: MISS` in responses
|
||||
- Higher hit rate = better performance
|
||||
|
||||
### Compression Ratio
|
||||
|
||||
Monitor bandwidth savings:
|
||||
- Compare compressed vs uncompressed sizes
|
||||
- Typical savings: 60-80% for JSON/text
|
||||
|
||||
## Configuration
|
||||
|
||||
### Cache TTL
|
||||
|
||||
Default: 5 minutes
|
||||
|
||||
To modify, edit `cache_middleware.go`:
|
||||
```go
|
||||
cache := NewResponseCache(5 * time.Minute) // Change TTL here
|
||||
```
|
||||
|
||||
### Compression
|
||||
|
||||
Automatic for all responses when client supports gzip.
|
||||
|
||||
To disable for specific endpoints, modify `compression_middleware.go`.
|
||||
|
||||
### Database Pool
|
||||
|
||||
Current settings:
|
||||
- Max Open: 25 connections
|
||||
- Max Idle: 5 connections
|
||||
- Max Lifetime: 5 minutes
|
||||
|
||||
To modify, edit `db/db.go`:
|
||||
```go
|
||||
conn.SetMaxOpenConns(25) // Adjust as needed
|
||||
conn.SetMaxIdleConns(5) // Adjust as needed
|
||||
conn.SetConnMaxLifetime(5 * time.Minute) // Adjust as needed
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Cache Statistics
|
||||
|
||||
Monitor cache performance:
|
||||
- Check `X-Cache` headers in responses
|
||||
- Track cache hit/miss ratios
|
||||
- Monitor cache size (future enhancement)
|
||||
|
||||
### Compression Statistics
|
||||
|
||||
Monitor compression effectiveness:
|
||||
- Check `Content-Encoding: gzip` in responses
|
||||
- Compare response sizes
|
||||
- Monitor bandwidth usage
|
||||
|
||||
### Database Performance
|
||||
|
||||
Monitor database:
|
||||
- Connection pool usage
|
||||
- Query performance
|
||||
- Connection lifetime
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Redis Cache**: Distributed caching for multi-instance deployments
|
||||
2. **Cache Statistics**: Detailed cache metrics endpoint
|
||||
3. **Configurable TTL**: Per-endpoint cache TTL configuration
|
||||
4. **Cache Warming**: Pre-populate cache for common requests
|
||||
5. **Compression Levels**: Configurable compression levels
|
||||
6. **Query Caching**: Cache database query results
|
||||
7. **Response Streaming**: Stream large responses
|
||||
8. **HTTP/2 Support**: Better multiplexing and compression
|
||||
9. **CDN Integration**: Edge caching for static resources
|
||||
10. **Performance Profiling**: Built-in performance profiler
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Cache Not Working
|
||||
|
||||
1. Check if endpoint is cacheable (GET request, public endpoint)
|
||||
2. Verify `X-Cache` header in response
|
||||
3. Check cache TTL hasn't expired
|
||||
4. Ensure endpoint isn't in skip list
|
||||
|
||||
### Compression Not Working
|
||||
|
||||
1. Verify client sends `Accept-Encoding: gzip` header
|
||||
2. Check response includes `Content-Encoding: gzip`
|
||||
3. Ensure content type isn't excluded (images, videos)
|
||||
|
||||
### Database Performance Issues
|
||||
|
||||
1. Check connection pool settings
|
||||
2. Monitor connection usage
|
||||
3. Verify WAL mode is enabled
|
||||
4. Check for long-running queries
|
||||
|
||||
## Performance Benchmarks
|
||||
|
||||
### Typical Improvements
|
||||
|
||||
- **Response Time**: 30-50% faster for cached responses
|
||||
- **Bandwidth**: 60-80% reduction with compression
|
||||
- **Database Load**: 40-60% reduction with caching
|
||||
- **Concurrent Requests**: 2-3x improvement with connection pooling
|
||||
|
||||
### Example Metrics
|
||||
|
||||
```
|
||||
Before Optimization:
|
||||
- Average response time: 150ms
|
||||
- Bandwidth per request: 10KB
|
||||
- Database queries per request: 3
|
||||
|
||||
After Optimization:
|
||||
- Average response time: 50ms (cached) / 120ms (uncached)
|
||||
- Bandwidth per request: 3KB (compressed)
|
||||
- Database queries per request: 1.2 (with caching)
|
||||
```
|
||||
150
docs/RBAC_PERMISSIONS.md
Normal file
150
docs/RBAC_PERMISSIONS.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# Role-Based Access Control (RBAC) - Current Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
AtlasOS implements a three-tier role-based access control system with the following roles:
|
||||
|
||||
1. **Administrator** (`administrator`) - Full system control
|
||||
2. **Operator** (`operator`) - Storage and service operations
|
||||
3. **Viewer** (`viewer`) - Read-only access
|
||||
|
||||
## Current Implementation Status
|
||||
|
||||
### ✅ Fully Implemented (Administrator-Only)
|
||||
|
||||
These operations **require Administrator role**:
|
||||
|
||||
- **User Management**: Create, update, delete users, list users
|
||||
- **Service Management**: Start, stop, restart, reload services, view service logs
|
||||
- **Maintenance Mode**: Enable/disable maintenance mode
|
||||
|
||||
### ⚠️ Partially Implemented (Authentication Required, No Role Check)
|
||||
|
||||
These operations **require authentication** but **don't check specific roles** (any authenticated user can perform them):
|
||||
|
||||
- **ZFS Operations**: Create/delete pools, datasets, ZVOLs, import/export pools, scrub operations
|
||||
- **Snapshot Management**: Create/delete snapshots, create/delete snapshot policies
|
||||
- **Storage Services**: Create/update/delete SMB shares, NFS exports, iSCSI targets
|
||||
- **Backup & Restore**: Create backups, restore backups
|
||||
|
||||
### ✅ Public (No Authentication Required)
|
||||
|
||||
These endpoints are **publicly accessible**:
|
||||
|
||||
- **Read-Only Operations**: List pools, datasets, ZVOLs, shares, exports, targets, snapshots
|
||||
- **Dashboard Data**: System statistics and health information
|
||||
- **Web UI Pages**: All HTML pages (authentication required for mutations via API)
|
||||
|
||||
## Role Definitions
|
||||
|
||||
### Administrator (`administrator`)
|
||||
- **Full system access**
|
||||
- Can manage users (create, update, delete)
|
||||
- Can manage services (start, stop, restart, reload)
|
||||
- Can enable/disable maintenance mode
|
||||
- Can perform all storage operations
|
||||
- Can view audit logs
|
||||
|
||||
### Operator (`operator`)
|
||||
- **Storage and service operations** (intended)
|
||||
- Currently: Same as authenticated user (can perform storage operations)
|
||||
- Should be able to: Create/manage pools, datasets, shares, snapshots
|
||||
- Should NOT be able to: Manage users, manage services, maintenance mode
|
||||
|
||||
### Viewer (`viewer`)
|
||||
- **Read-only access** (intended)
|
||||
- Currently: Can view all public data
|
||||
- Should be able to: View all system information
|
||||
- Should NOT be able to: Perform any mutations (create, update, delete)
|
||||
|
||||
## Current Permission Matrix
|
||||
|
||||
| Operation | Administrator | Operator | Viewer | Unauthenticated |
|
||||
|-----------|--------------|----------|--------|-----------------|
|
||||
| **User Management** |
|
||||
| List users | ✅ | ❌ | ❌ | ❌ |
|
||||
| Create user | ✅ | ❌ | ❌ | ❌ |
|
||||
| Update user | ✅ | ❌ | ❌ | ❌ |
|
||||
| Delete user | ✅ | ❌ | ❌ | ❌ |
|
||||
| **Service Management** |
|
||||
| View service status | ✅ | ❌ | ❌ | ❌ |
|
||||
| Start/stop/restart service | ✅ | ❌ | ❌ | ❌ |
|
||||
| View service logs | ✅ | ❌ | ❌ | ❌ |
|
||||
| **Storage Operations** |
|
||||
| List pools/datasets/ZVOLs | ✅ | ✅ | ✅ | ✅ (public) |
|
||||
| Create pool/dataset/ZVOL | ✅ | ✅* | ❌ | ❌ |
|
||||
| Delete pool/dataset/ZVOL | ✅ | ✅* | ❌ | ❌ |
|
||||
| Import/export pool | ✅ | ✅* | ❌ | ❌ |
|
||||
| **Share Management** |
|
||||
| List shares/exports/targets | ✅ | ✅ | ✅ | ✅ (public) |
|
||||
| Create share/export/target | ✅ | ✅* | ❌ | ❌ |
|
||||
| Update share/export/target | ✅ | ✅* | ❌ | ❌ |
|
||||
| Delete share/export/target | ✅ | ✅* | ❌ | ❌ |
|
||||
| **Snapshot Management** |
|
||||
| List snapshots/policies | ✅ | ✅ | ✅ | ✅ (public) |
|
||||
| Create snapshot/policy | ✅ | ✅* | ❌ | ❌ |
|
||||
| Delete snapshot/policy | ✅ | ✅* | ❌ | ❌ |
|
||||
| **Maintenance Mode** |
|
||||
| View status | ✅ | ✅ | ✅ | ✅ (public) |
|
||||
| Enable/disable | ✅ | ❌ | ❌ | ❌ |
|
||||
|
||||
*Currently works but not explicitly restricted - any authenticated user can perform these operations
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Role Checking
|
||||
|
||||
Roles are checked using the `requireRole()` middleware:
|
||||
|
||||
```go
|
||||
// Example: Administrator-only endpoint
|
||||
a.mux.HandleFunc("/api/v1/users", methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleListUsers(w, r) },
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
adminRole := models.RoleAdministrator
|
||||
a.requireRole(adminRole)(http.HandlerFunc(a.handleCreateUser)).ServeHTTP(w, r)
|
||||
},
|
||||
nil, nil, nil,
|
||||
))
|
||||
```
|
||||
|
||||
### Multiple Roles Support
|
||||
|
||||
The `requireRole()` function accepts multiple roles:
|
||||
|
||||
```go
|
||||
// Allow both Administrator and Operator
|
||||
a.requireRole(models.RoleAdministrator, models.RoleOperator)(handler)
|
||||
```
|
||||
|
||||
### Current Limitations
|
||||
|
||||
1. **No Operator/Viewer Differentiation**: Most storage operations don't check roles - they only require authentication
|
||||
2. **Hardcoded Role Checks**: Role permissions are defined in route handlers, not in a centralized permission matrix
|
||||
3. **No Granular Permissions**: Can't assign specific permissions (e.g., "can create pools but not delete them")
|
||||
|
||||
## Future Improvements
|
||||
|
||||
To properly implement Operator and Viewer roles:
|
||||
|
||||
1. **Add Role Checks to Storage Operations**:
|
||||
- Allow Operator and Administrator for create/update/delete operations
|
||||
- Restrict Viewer to read-only (GET requests only)
|
||||
|
||||
2. **Centralize Permission Matrix**:
|
||||
- Create a permission configuration file or database table
|
||||
- Map operations to required roles
|
||||
|
||||
3. **Granular Permissions** (Future):
|
||||
- Allow custom permission sets
|
||||
- Support resource-level permissions (e.g., "can manage pool X but not pool Y")
|
||||
|
||||
## Testing Roles
|
||||
|
||||
To test different roles:
|
||||
|
||||
1. Create users with different roles via the Management page
|
||||
2. Login as each user
|
||||
3. Attempt operations and verify permissions
|
||||
|
||||
**Note**: Currently, most operations work for any authenticated user. Only user management, service management, and maintenance mode are properly restricted to Administrators.
|
||||
163
docs/SERVICE_INTEGRATION.md
Normal file
163
docs/SERVICE_INTEGRATION.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# Service Daemon Integration
|
||||
|
||||
## Overview
|
||||
|
||||
AtlasOS integrates with system storage daemons (Samba, NFS, iSCSI) to automatically apply configuration changes. When storage services are created, updated, or deleted via the API, the system daemons are automatically reconfigured.
|
||||
|
||||
## Architecture
|
||||
|
||||
The service integration layer (`internal/services/`) provides:
|
||||
|
||||
- **Configuration Generation**: Converts API models to daemon-specific configuration formats
|
||||
- **Atomic Updates**: Writes to temporary files, then atomically replaces configuration
|
||||
- **Safe Reloads**: Reloads services without interrupting active connections
|
||||
- **Error Recovery**: Automatically restores backups on configuration failures
|
||||
|
||||
## SMB/Samba Integration
|
||||
|
||||
### Configuration
|
||||
- **Config File**: `/etc/samba/smb.conf`
|
||||
- **Service**: `smbd` (Samba daemon)
|
||||
- **Reload Method**: `smbcontrol all reload-config` or `systemctl reload smbd`
|
||||
|
||||
### Features
|
||||
- Generates Samba configuration from SMB share definitions
|
||||
- Supports read-only, guest access, and user restrictions
|
||||
- Automatically reloads Samba after configuration changes
|
||||
- Validates configuration syntax using `testparm`
|
||||
|
||||
### Example
|
||||
When an SMB share is created via API:
|
||||
1. Share is stored in the store
|
||||
2. All shares are retrieved
|
||||
3. Samba configuration is generated
|
||||
4. Configuration is written to `/etc/samba/smb.conf`
|
||||
5. Samba service is reloaded
|
||||
|
||||
## NFS Integration
|
||||
|
||||
### Configuration
|
||||
- **Config File**: `/etc/exports`
|
||||
- **Service**: `nfs-server`
|
||||
- **Reload Method**: `exportfs -ra`
|
||||
|
||||
### Features
|
||||
- Generates `/etc/exports` format from NFS export definitions
|
||||
- Supports read-only, client restrictions, and root squash
|
||||
- Automatically reloads NFS exports after configuration changes
|
||||
- Handles multiple clients per export
|
||||
|
||||
### Example
|
||||
When an NFS export is created via API:
|
||||
1. Export is stored in the store
|
||||
2. All exports are retrieved
|
||||
3. `/etc/exports` is generated
|
||||
4. Exports file is written atomically
|
||||
5. NFS exports are reloaded using `exportfs -ra`
|
||||
|
||||
## iSCSI Integration
|
||||
|
||||
### Configuration
|
||||
- **Tool**: `targetcli` (LIO target framework)
|
||||
- **Service**: `target` (systemd service)
|
||||
- **Method**: Direct targetcli commands
|
||||
|
||||
### Features
|
||||
- Creates iSCSI targets with IQN
|
||||
- Configures initiator ACLs
|
||||
- Maps ZVOLs as LUNs
|
||||
- Manages target enable/disable state
|
||||
|
||||
### Example
|
||||
When an iSCSI target is created via API:
|
||||
1. Target is stored in the store
|
||||
2. All targets are retrieved
|
||||
3. For each target:
|
||||
- Target is created via `targetcli`
|
||||
- Initiator ACLs are configured
|
||||
- LUNs are mapped to ZVOLs
|
||||
4. Configuration is applied atomically
|
||||
|
||||
## Safety Features
|
||||
|
||||
### Atomic Configuration Updates
|
||||
1. Write configuration to temporary file (`*.atlas.tmp`)
|
||||
2. Backup existing configuration (`.backup`)
|
||||
3. Atomically replace configuration file
|
||||
4. Reload service
|
||||
5. On failure, restore backup
|
||||
|
||||
### Error Handling
|
||||
- Configuration errors are logged but don't fail API requests
|
||||
- Service reload failures trigger automatic backup restoration
|
||||
- Validation is performed before applying changes (where supported)
|
||||
|
||||
## Service Status
|
||||
|
||||
Each service provides a `GetStatus()` method to check if the daemon is running:
|
||||
|
||||
```go
|
||||
// Check Samba status
|
||||
running, err := smbService.GetStatus()
|
||||
|
||||
// Check NFS status
|
||||
running, err := nfsService.GetStatus()
|
||||
|
||||
// Check iSCSI status
|
||||
running, err := iscsiService.GetStatus()
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
### Samba
|
||||
- `samba` package installed
|
||||
- `smbcontrol` command available
|
||||
- Write access to `/etc/samba/smb.conf`
|
||||
- Root/sudo privileges for service reload
|
||||
|
||||
### NFS
|
||||
- `nfs-kernel-server` package installed
|
||||
- `exportfs` command available
|
||||
- Write access to `/etc/exports`
|
||||
- Root/sudo privileges for export reload
|
||||
|
||||
### iSCSI
|
||||
- `targetcli` package installed
|
||||
- LIO target framework enabled
|
||||
- Root/sudo privileges for targetcli operations
|
||||
- ZVOL backend support
|
||||
|
||||
## Configuration Flow
|
||||
|
||||
```
|
||||
API Request → Store Update → Service Integration → Daemon Configuration
|
||||
↓ ↓ ↓ ↓
|
||||
Create/ Store in Generate Config Write & Reload
|
||||
Update/ Memory/DB from Models Service
|
||||
Delete
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Async Configuration**: Queue configuration changes for background processing
|
||||
2. **Validation API**: Pre-validate configurations before applying
|
||||
3. **Rollback Support**: Automatic rollback on service failures
|
||||
4. **Status Monitoring**: Real-time service health monitoring
|
||||
5. **Configuration Diff**: Show what will change before applying
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Samba Configuration Not Applied
|
||||
- Check Samba service status: `systemctl status smbd`
|
||||
- Validate configuration: `testparm -s`
|
||||
- Check logs: `journalctl -u smbd`
|
||||
|
||||
### NFS Exports Not Working
|
||||
- Check NFS service status: `systemctl status nfs-server`
|
||||
- Verify exports: `exportfs -v`
|
||||
- Check permissions on exported paths
|
||||
|
||||
### iSCSI Targets Not Created
|
||||
- Verify targetcli is installed: `which targetcli`
|
||||
- Check LIO service: `systemctl status target`
|
||||
- Review targetcli output for errors
|
||||
366
docs/TESTING.md
Normal file
366
docs/TESTING.md
Normal file
@@ -0,0 +1,366 @@
|
||||
# Testing Infrastructure
|
||||
|
||||
## Overview
|
||||
|
||||
AtlasOS includes a comprehensive testing infrastructure with unit tests, integration tests, and test utilities to ensure code quality and reliability.
|
||||
|
||||
## Test Structure
|
||||
|
||||
```
|
||||
atlas/
|
||||
├── internal/
|
||||
│ ├── validation/
|
||||
│ │ └── validator_test.go # Unit tests for validation
|
||||
│ ├── errors/
|
||||
│ │ └── errors_test.go # Unit tests for error handling
|
||||
│ └── testing/
|
||||
│ └── helpers.go # Test utilities and helpers
|
||||
└── test/
|
||||
└── integration_test.go # Integration tests
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Run All Tests
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
### Run Tests for Specific Package
|
||||
|
||||
```bash
|
||||
# Validation tests
|
||||
go test ./internal/validation -v
|
||||
|
||||
# Error handling tests
|
||||
go test ./internal/errors -v
|
||||
|
||||
# Integration tests
|
||||
go test ./test -v
|
||||
```
|
||||
|
||||
### Run Tests with Coverage
|
||||
|
||||
```bash
|
||||
go test ./... -cover
|
||||
```
|
||||
|
||||
### Generate Coverage Report
|
||||
|
||||
```bash
|
||||
go test ./... -coverprofile=coverage.out
|
||||
go tool cover -html=coverage.out
|
||||
```
|
||||
|
||||
## Unit Tests
|
||||
|
||||
### Validation Tests
|
||||
|
||||
Tests for input validation functions:
|
||||
|
||||
```bash
|
||||
go test ./internal/validation -v
|
||||
```
|
||||
|
||||
**Coverage:**
|
||||
- ZFS name validation
|
||||
- Username validation
|
||||
- Password validation
|
||||
- Email validation
|
||||
- Share name validation
|
||||
- IQN validation
|
||||
- Size format validation
|
||||
- Path validation
|
||||
- CIDR validation
|
||||
- String sanitization
|
||||
- Path sanitization
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
func TestValidateZFSName(t *testing.T) {
|
||||
err := ValidateZFSName("tank")
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for valid name")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling Tests
|
||||
|
||||
Tests for error handling and API errors:
|
||||
|
||||
```bash
|
||||
go test ./internal/errors -v
|
||||
```
|
||||
|
||||
**Coverage:**
|
||||
- Error code validation
|
||||
- HTTP status code mapping
|
||||
- Error message formatting
|
||||
- Error details attachment
|
||||
|
||||
## Integration Tests
|
||||
|
||||
### Test Server
|
||||
|
||||
The integration test framework provides a test server:
|
||||
|
||||
```go
|
||||
ts := NewTestServer(t)
|
||||
defer ts.Close()
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- In-memory database for tests
|
||||
- Test HTTP client
|
||||
- Authentication helpers
|
||||
- Request helpers
|
||||
|
||||
### Authentication Testing
|
||||
|
||||
```go
|
||||
// Login and get token
|
||||
ts.Login(t, "admin", "admin")
|
||||
|
||||
// Make authenticated request
|
||||
resp := ts.Get(t, "/api/v1/pools")
|
||||
```
|
||||
|
||||
### Request Helpers
|
||||
|
||||
```go
|
||||
// GET request
|
||||
resp := ts.Get(t, "/api/v1/pools")
|
||||
|
||||
// POST request
|
||||
resp := ts.Post(t, "/api/v1/pools", map[string]interface{}{
|
||||
"name": "tank",
|
||||
"vdevs": []string{"/dev/sda"},
|
||||
})
|
||||
```
|
||||
|
||||
## Test Utilities
|
||||
|
||||
### Test Helpers Package
|
||||
|
||||
The `internal/testing` package provides utilities:
|
||||
|
||||
**MakeRequest**: Create and execute HTTP requests
|
||||
```go
|
||||
recorder := MakeRequest(t, handler, TestRequest{
|
||||
Method: "GET",
|
||||
Path: "/api/v1/pools",
|
||||
})
|
||||
```
|
||||
|
||||
**Assertions**:
|
||||
- `AssertStatusCode`: Check HTTP status code
|
||||
- `AssertJSONResponse`: Validate JSON response
|
||||
- `AssertErrorResponse`: Check error response format
|
||||
- `AssertSuccessResponse`: Validate success response
|
||||
- `AssertHeader`: Check response headers
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
recorder := MakeRequest(t, handler, TestRequest{
|
||||
Method: "GET",
|
||||
Path: "/api/v1/pools",
|
||||
})
|
||||
|
||||
AssertStatusCode(t, recorder, http.StatusOK)
|
||||
response := AssertJSONResponse(t, recorder)
|
||||
```
|
||||
|
||||
### Mock Clients
|
||||
|
||||
**MockZFSClient**: Mock ZFS client for testing
|
||||
|
||||
```go
|
||||
mockClient := NewMockZFSClient()
|
||||
mockClient.AddPool(map[string]interface{}{
|
||||
"name": "tank",
|
||||
"size": "10TB",
|
||||
})
|
||||
```
|
||||
|
||||
## Writing Tests
|
||||
|
||||
### Unit Test Template
|
||||
|
||||
```go
|
||||
func TestFunctionName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid input", "valid", false},
|
||||
{"invalid input", "invalid", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := FunctionName(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("FunctionName(%q) error = %v, wantErr %v",
|
||||
tt.input, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Integration Test Template
|
||||
|
||||
```go
|
||||
func TestEndpoint(t *testing.T) {
|
||||
ts := NewTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
ts.Login(t, "admin", "admin")
|
||||
|
||||
resp := ts.Get(t, "/api/v1/endpoint")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Test Coverage Goals
|
||||
|
||||
### Current Coverage
|
||||
|
||||
- **Validation Package**: ~95% coverage
|
||||
- **Error Package**: ~90% coverage
|
||||
- **Integration Tests**: Core endpoints covered
|
||||
|
||||
### Target Coverage
|
||||
|
||||
- **Unit Tests**: >80% coverage for all packages
|
||||
- **Integration Tests**: All API endpoints
|
||||
- **Edge Cases**: Error conditions and boundary cases
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Test Naming
|
||||
|
||||
Use descriptive test names:
|
||||
```go
|
||||
func TestValidateZFSName_ValidName_ReturnsNoError(t *testing.T) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Table-Driven Tests
|
||||
|
||||
Use table-driven tests for multiple cases:
|
||||
```go
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
// test cases
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Test Isolation
|
||||
|
||||
Each test should be independent:
|
||||
```go
|
||||
func TestSomething(t *testing.T) {
|
||||
// Setup
|
||||
ts := NewTestServer(t)
|
||||
defer ts.Close() // Cleanup
|
||||
|
||||
// Test
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Error Testing
|
||||
|
||||
Test both success and error cases:
|
||||
```go
|
||||
// Success case
|
||||
err := ValidateZFSName("tank")
|
||||
if err != nil {
|
||||
t.Error("expected no error")
|
||||
}
|
||||
|
||||
// Error case
|
||||
err = ValidateZFSName("")
|
||||
if err == nil {
|
||||
t.Error("expected error for empty name")
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Use Test Helpers
|
||||
|
||||
Use helper functions for common patterns:
|
||||
```go
|
||||
recorder := MakeRequest(t, handler, TestRequest{
|
||||
Method: "GET",
|
||||
Path: "/api/v1/pools",
|
||||
})
|
||||
AssertStatusCode(t, recorder, http.StatusOK)
|
||||
```
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
### GitHub Actions Example
|
||||
|
||||
```yaml
|
||||
name: Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: '1.21'
|
||||
- run: go test ./... -v
|
||||
- run: go test ./... -coverprofile=coverage.out
|
||||
- run: go tool cover -func=coverage.out
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **More Unit Tests**: Expand coverage for all packages
|
||||
2. **Integration Tests**: Complete API endpoint coverage
|
||||
3. **Performance Tests**: Benchmark critical paths
|
||||
4. **Load Tests**: Stress testing with high concurrency
|
||||
5. **Mock Services**: Mock external dependencies
|
||||
6. **Test Fixtures**: Reusable test data
|
||||
7. **Golden Files**: Compare outputs to expected results
|
||||
8. **Fuzzing**: Property-based testing
|
||||
9. **Race Detection**: Test for race conditions
|
||||
10. **Test Documentation**: Generate test documentation
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Tests Failing
|
||||
|
||||
1. **Check Test Output**: Run with `-v` flag for verbose output
|
||||
2. **Check Dependencies**: Ensure all dependencies are available
|
||||
3. **Check Environment**: Verify test environment setup
|
||||
4. **Check Test Data**: Ensure test data is correct
|
||||
|
||||
### Coverage Issues
|
||||
|
||||
1. **Run Coverage**: `go test ./... -cover`
|
||||
2. **View Report**: `go tool cover -html=coverage.out`
|
||||
3. **Identify Gaps**: Look for untested code paths
|
||||
4. **Add Tests**: Write tests for uncovered code
|
||||
|
||||
### Integration Test Issues
|
||||
|
||||
1. **Check Server**: Verify test server starts correctly
|
||||
2. **Check Database**: Ensure in-memory database works
|
||||
3. **Check Auth**: Verify authentication in tests
|
||||
4. **Check Cleanup**: Ensure proper cleanup after tests
|
||||
376
docs/TUI.md
Normal file
376
docs/TUI.md
Normal file
@@ -0,0 +1,376 @@
|
||||
# Terminal User Interface (TUI)
|
||||
|
||||
## Overview
|
||||
|
||||
AtlasOS provides a Terminal User Interface (TUI) for managing the storage system from the command line. The TUI provides an interactive menu-driven interface that connects to the Atlas API.
|
||||
|
||||
## Features
|
||||
|
||||
- **Interactive Menus**: Navigate through system features with simple menus
|
||||
- **Authentication**: Secure login to the API
|
||||
- **ZFS Management**: View and manage pools, datasets, and ZVOLs
|
||||
- **Storage Services**: Manage SMB shares, NFS exports, and iSCSI targets
|
||||
- **Snapshot Management**: Create snapshots and manage policies
|
||||
- **System Information**: View system health and diagnostics
|
||||
- **Backup & Restore**: Manage configuration backups
|
||||
|
||||
## Installation
|
||||
|
||||
Build the TUI binary:
|
||||
|
||||
```bash
|
||||
go build ./cmd/atlas-tui
|
||||
```
|
||||
|
||||
Or use the Makefile:
|
||||
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
|
||||
This creates the `atlas-tui` binary.
|
||||
|
||||
## Configuration
|
||||
|
||||
### API URL
|
||||
|
||||
Set the API URL via environment variable:
|
||||
|
||||
```bash
|
||||
export ATLAS_API_URL=http://localhost:8080
|
||||
./atlas-tui
|
||||
```
|
||||
|
||||
Default: `http://localhost:8080`
|
||||
|
||||
## Usage
|
||||
|
||||
### Starting the TUI
|
||||
|
||||
```bash
|
||||
./atlas-tui
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
On first run, you'll be prompted to login:
|
||||
|
||||
```
|
||||
=== AtlasOS Login ===
|
||||
Username: admin
|
||||
Password: ****
|
||||
Login successful!
|
||||
```
|
||||
|
||||
### Main Menu
|
||||
|
||||
```
|
||||
=== AtlasOS Terminal Interface ===
|
||||
1. ZFS Management
|
||||
2. Storage Services
|
||||
3. Snapshots
|
||||
4. System Information
|
||||
5. Backup & Restore
|
||||
0. Exit
|
||||
```
|
||||
|
||||
## Menu Options
|
||||
|
||||
### 1. ZFS Management
|
||||
|
||||
**Sub-menu:**
|
||||
- List Pools
|
||||
- List Datasets
|
||||
- List ZVOLs
|
||||
- List Disks
|
||||
|
||||
**Example - List Pools:**
|
||||
```
|
||||
=== ZFS Pools ===
|
||||
1. tank
|
||||
Size: 10TB
|
||||
Used: 2TB
|
||||
```
|
||||
|
||||
### 2. Storage Services
|
||||
|
||||
**Sub-menu:**
|
||||
- SMB Shares
|
||||
- NFS Exports
|
||||
- iSCSI Targets
|
||||
|
||||
**SMB Shares:**
|
||||
- List Shares
|
||||
- Create Share
|
||||
|
||||
**Example - Create SMB Share:**
|
||||
```
|
||||
Share name: data-share
|
||||
Dataset: tank/data
|
||||
Path (optional, press Enter to auto-detect):
|
||||
Description (optional): Main data share
|
||||
SMB share created successfully!
|
||||
Share: data-share
|
||||
```
|
||||
|
||||
**NFS Exports:**
|
||||
- List Exports
|
||||
- Create Export
|
||||
|
||||
**Example - Create NFS Export:**
|
||||
```
|
||||
Dataset: tank/data
|
||||
Path (optional, press Enter to auto-detect):
|
||||
Clients (comma-separated, e.g., 192.168.1.0/24,*): 192.168.1.0/24
|
||||
NFS export created successfully!
|
||||
Export: /tank/data
|
||||
```
|
||||
|
||||
**iSCSI Targets:**
|
||||
- List Targets
|
||||
- Create Target
|
||||
|
||||
**Example - Create iSCSI Target:**
|
||||
```
|
||||
IQN (e.g., iqn.2024-12.com.atlas:target1): iqn.2024-12.com.atlas:target1
|
||||
iSCSI target created successfully!
|
||||
Target: iqn.2024-12.com.atlas:target1
|
||||
```
|
||||
|
||||
### 3. Snapshots
|
||||
|
||||
**Sub-menu:**
|
||||
- List Snapshots
|
||||
- Create Snapshot
|
||||
- List Snapshot Policies
|
||||
|
||||
**Example - Create Snapshot:**
|
||||
```
|
||||
Dataset name: tank/data
|
||||
Snapshot name: backup-2024-12-20
|
||||
Snapshot created successfully!
|
||||
Snapshot: tank/data@backup-2024-12-20
|
||||
```
|
||||
|
||||
### 4. System Information
|
||||
|
||||
**Sub-menu:**
|
||||
- System Info
|
||||
- Health Check
|
||||
- Dashboard
|
||||
|
||||
**System Info:**
|
||||
```
|
||||
=== System Information ===
|
||||
Version: v0.1.0-dev
|
||||
Uptime: 3600 seconds
|
||||
Go Version: go1.21.0
|
||||
Goroutines: 15
|
||||
|
||||
Services:
|
||||
smb: running
|
||||
nfs: running
|
||||
iscsi: stopped
|
||||
```
|
||||
|
||||
**Health Check:**
|
||||
```
|
||||
=== Health Check ===
|
||||
Status: healthy
|
||||
|
||||
Component Checks:
|
||||
zfs: healthy
|
||||
database: healthy
|
||||
smb: healthy
|
||||
nfs: healthy
|
||||
iscsi: stopped
|
||||
```
|
||||
|
||||
**Dashboard:**
|
||||
```
|
||||
=== Dashboard ===
|
||||
Pools: 2
|
||||
Datasets: 10
|
||||
SMB Shares: 5
|
||||
NFS Exports: 3
|
||||
iSCSI Targets: 2
|
||||
```
|
||||
|
||||
### 5. Backup & Restore
|
||||
|
||||
**Sub-menu:**
|
||||
- List Backups
|
||||
- Create Backup
|
||||
- Restore Backup
|
||||
|
||||
**Example - Create Backup:**
|
||||
```
|
||||
Description (optional): Weekly backup
|
||||
Backup created successfully!
|
||||
Backup ID: backup-1703123456
|
||||
```
|
||||
|
||||
**Example - Restore Backup:**
|
||||
```
|
||||
=== Backups ===
|
||||
1. backup-1703123456
|
||||
Created: 2024-12-20T10:30:56Z
|
||||
Description: Weekly backup
|
||||
|
||||
Backup ID: backup-1703123456
|
||||
Restore backup? This will overwrite current configuration. (yes/no): yes
|
||||
Backup restored successfully!
|
||||
```
|
||||
|
||||
## Navigation
|
||||
|
||||
- **Select Option**: Enter the number or letter corresponding to the menu option
|
||||
- **Back**: Enter `0` to go back to the previous menu
|
||||
- **Exit**: Enter `0`, `q`, or `exit` to quit the application
|
||||
- **Interrupt**: Press `Ctrl+C` for graceful shutdown
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
- `Ctrl+C`: Graceful shutdown
|
||||
- `0`: Back/Exit
|
||||
- `q`: Exit
|
||||
- `exit`: Exit
|
||||
|
||||
## Examples
|
||||
|
||||
### Complete Workflow
|
||||
|
||||
```bash
|
||||
# Start TUI
|
||||
./atlas-tui
|
||||
|
||||
# Login
|
||||
Username: admin
|
||||
Password: admin
|
||||
|
||||
# Navigate to ZFS Management
|
||||
Select option: 1
|
||||
|
||||
# List pools
|
||||
Select option: 1
|
||||
|
||||
# Go back
|
||||
Select option: 0
|
||||
|
||||
# Create SMB share
|
||||
Select option: 2
|
||||
Select option: 1
|
||||
Select option: 2
|
||||
Share name: myshare
|
||||
Dataset: tank/data
|
||||
...
|
||||
|
||||
# Exit
|
||||
Select option: 0
|
||||
Select option: 0
|
||||
```
|
||||
|
||||
## API Client
|
||||
|
||||
The TUI uses an HTTP client to communicate with the Atlas API:
|
||||
|
||||
- **Authentication**: JWT token-based authentication
|
||||
- **Error Handling**: Clear error messages for API failures
|
||||
- **Timeout**: 30-second timeout for requests
|
||||
|
||||
## Error Handling
|
||||
|
||||
The TUI handles errors gracefully:
|
||||
|
||||
- **Connection Errors**: Clear messages when API is unreachable
|
||||
- **Authentication Errors**: Prompts for re-authentication
|
||||
- **API Errors**: Displays error messages from API responses
|
||||
- **Invalid Input**: Validates user input before sending requests
|
||||
|
||||
## Configuration File
|
||||
|
||||
Future enhancement: Support for configuration file:
|
||||
|
||||
```yaml
|
||||
api_url: http://localhost:8080
|
||||
username: admin
|
||||
# Token can be stored (with appropriate security)
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Password Input**: Currently visible (future: hidden input)
|
||||
2. **Token Storage**: Token stored in memory only
|
||||
3. **HTTPS**: Use HTTPS for production API URLs
|
||||
4. **Credentials**: Never log credentials
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Password Visibility**: Passwords are currently visible during input
|
||||
- **No Token Persistence**: Must login on each TUI start
|
||||
- **Basic Interface**: Text-based menus (not a full TUI library)
|
||||
- **Limited Error Recovery**: Some errors require restart
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Hidden Password Input**: Use library to hide password input
|
||||
2. **Token Persistence**: Store token securely for session persistence
|
||||
3. **Advanced TUI**: Use Bubble Tea or similar for rich interface
|
||||
4. **Command Mode**: Support command-line arguments for non-interactive use
|
||||
5. **Configuration File**: Support for config file
|
||||
6. **Auto-completion**: Tab completion for commands
|
||||
7. **History**: Command history support
|
||||
8. **Color Output**: Colored output for better readability
|
||||
9. **Progress Indicators**: Show progress for long operations
|
||||
10. **Batch Operations**: Support for batch operations
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection Errors
|
||||
|
||||
```
|
||||
Error: request failed: dial tcp 127.0.0.1:8080: connect: connection refused
|
||||
```
|
||||
|
||||
**Solution**: Ensure the API server is running:
|
||||
```bash
|
||||
./atlas-api
|
||||
```
|
||||
|
||||
### Authentication Errors
|
||||
|
||||
```
|
||||
Error: login failed: invalid credentials
|
||||
```
|
||||
|
||||
**Solution**: Check username and password. Default credentials:
|
||||
- Username: `admin`
|
||||
- Password: `admin`
|
||||
|
||||
### API URL Configuration
|
||||
|
||||
If API is on a different host/port:
|
||||
|
||||
```bash
|
||||
export ATLAS_API_URL=http://192.168.1.100:8080
|
||||
./atlas-tui
|
||||
```
|
||||
|
||||
## Comparison with Web GUI
|
||||
|
||||
| Feature | TUI | Web GUI |
|
||||
|---------|-----|---------|
|
||||
| **Access** | Local console | Browser |
|
||||
| **Setup** | No browser needed | Requires browser |
|
||||
| **Network** | Works offline (local) | Requires network |
|
||||
| **Rich UI** | Text-based | HTML/CSS/JS |
|
||||
| **Initial Setup** | Ideal for setup | Better for daily use |
|
||||
| **Maintenance** | Good for maintenance | Good for monitoring |
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use TUI for Initial Setup**: TUI is ideal for initial system configuration
|
||||
2. **Use Web GUI for Daily Operations**: Web GUI provides better visualization
|
||||
3. **Keep API Running**: TUI requires the API server to be running
|
||||
4. **Secure Credentials**: Don't share credentials or tokens
|
||||
5. **Use HTTPS in Production**: Always use HTTPS for production API URLs
|
||||
232
docs/VALIDATION.md
Normal file
232
docs/VALIDATION.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# Input Validation & Sanitization
|
||||
|
||||
## Overview
|
||||
|
||||
AtlasOS implements comprehensive input validation and sanitization to ensure data integrity, security, and prevent injection attacks. All user inputs are validated before processing.
|
||||
|
||||
## Validation Rules
|
||||
|
||||
### ZFS Names (Pools, Datasets, ZVOLs, Snapshots)
|
||||
|
||||
**Rules:**
|
||||
- Must start with alphanumeric character
|
||||
- Can contain: `a-z`, `A-Z`, `0-9`, `_`, `-`, `.`, `:`
|
||||
- Cannot start with `-` or `.`
|
||||
- Maximum length: 256 characters
|
||||
- Cannot be empty
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
if err := validation.ValidateZFSName("tank/data"); err != nil {
|
||||
// Handle error
|
||||
}
|
||||
```
|
||||
|
||||
### Usernames
|
||||
|
||||
**Rules:**
|
||||
- Minimum length: 3 characters
|
||||
- Maximum length: 32 characters
|
||||
- Can contain: `a-z`, `A-Z`, `0-9`, `_`, `-`, `.`
|
||||
- Must start with alphanumeric character
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
if err := validation.ValidateUsername("admin"); err != nil {
|
||||
// Handle error
|
||||
}
|
||||
```
|
||||
|
||||
### Passwords
|
||||
|
||||
**Rules:**
|
||||
- Minimum length: 8 characters
|
||||
- Maximum length: 128 characters
|
||||
- Must contain at least one letter
|
||||
- Must contain at least one number
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
if err := validation.ValidatePassword("SecurePass123"); err != nil {
|
||||
// Handle error
|
||||
}
|
||||
```
|
||||
|
||||
### Email Addresses
|
||||
|
||||
**Rules:**
|
||||
- Optional field (can be empty)
|
||||
- Maximum length: 254 characters
|
||||
- Must match email format pattern
|
||||
- Basic format validation (RFC 5322 simplified)
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
if err := validation.ValidateEmail("user@example.com"); err != nil {
|
||||
// Handle error
|
||||
}
|
||||
```
|
||||
|
||||
### SMB Share Names
|
||||
|
||||
**Rules:**
|
||||
- Maximum length: 80 characters
|
||||
- Can contain: `a-z`, `A-Z`, `0-9`, `_`, `-`, `.`
|
||||
- Cannot be reserved Windows names (CON, PRN, AUX, NUL, COM1-9, LPT1-9)
|
||||
- Must start with alphanumeric character
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
if err := validation.ValidateShareName("data-share"); err != nil {
|
||||
// Handle error
|
||||
}
|
||||
```
|
||||
|
||||
### iSCSI IQN (Qualified Name)
|
||||
|
||||
**Rules:**
|
||||
- Must start with `iqn.`
|
||||
- Format: `iqn.yyyy-mm.reversed.domain:identifier`
|
||||
- Maximum length: 223 characters
|
||||
- Year-month format validation
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
if err := validation.ValidateIQN("iqn.2024-12.com.atlas:storage.target1"); err != nil {
|
||||
// Handle error
|
||||
}
|
||||
```
|
||||
|
||||
### Size Strings
|
||||
|
||||
**Rules:**
|
||||
- Format: number followed by optional unit (K, M, G, T, P)
|
||||
- Units: K (kilobytes), M (megabytes), G (gigabytes), T (terabytes), P (petabytes)
|
||||
- Case insensitive
|
||||
|
||||
**Examples:**
|
||||
- `"10"` - 10 bytes
|
||||
- `"10K"` - 10 kilobytes
|
||||
- `"1G"` - 1 gigabyte
|
||||
- `"2T"` - 2 terabytes
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
if err := validation.ValidateSize("10G"); err != nil {
|
||||
// Handle error
|
||||
}
|
||||
```
|
||||
|
||||
### Filesystem Paths
|
||||
|
||||
**Rules:**
|
||||
- Must be absolute (start with `/`)
|
||||
- Maximum length: 4096 characters
|
||||
- Cannot contain `..` (path traversal)
|
||||
- Cannot contain `//` (double slashes)
|
||||
- Cannot contain null bytes
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
if err := validation.ValidatePath("/tank/data"); err != nil {
|
||||
// Handle error
|
||||
}
|
||||
```
|
||||
|
||||
### CIDR/Hostname (NFS Clients)
|
||||
|
||||
**Rules:**
|
||||
- Can be wildcard: `*`
|
||||
- Can be CIDR notation: `192.168.1.0/24`
|
||||
- Can be hostname: `server.example.com`
|
||||
- Hostname must follow DNS rules
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
if err := validation.ValidateCIDR("192.168.1.0/24"); err != nil {
|
||||
// Handle error
|
||||
}
|
||||
```
|
||||
|
||||
## Sanitization
|
||||
|
||||
### String Sanitization
|
||||
|
||||
Removes potentially dangerous characters:
|
||||
- Null bytes (`\x00`)
|
||||
- Control characters (ASCII < 32, except space)
|
||||
- Removes leading/trailing whitespace
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
clean := validation.SanitizeString(userInput)
|
||||
```
|
||||
|
||||
### Path Sanitization
|
||||
|
||||
Normalizes filesystem paths:
|
||||
- Removes leading/trailing whitespace
|
||||
- Normalizes slashes (backslash to forward slash)
|
||||
- Removes multiple consecutive slashes
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
cleanPath := validation.SanitizePath("/tank//data/")
|
||||
// Result: "/tank/data"
|
||||
```
|
||||
|
||||
## Integration
|
||||
|
||||
### In API Handlers
|
||||
|
||||
Validation is integrated into all create/update handlers:
|
||||
|
||||
```go
|
||||
func (a *App) handleCreatePool(w http.ResponseWriter, r *http.Request) {
|
||||
// ... decode request ...
|
||||
|
||||
// Validate pool name
|
||||
if err := validation.ValidateZFSName(req.Name); err != nil {
|
||||
writeError(w, errors.ErrValidation(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// ... continue with creation ...
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
|
||||
Validation errors return structured error responses:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "validation error on field 'name': name cannot be empty",
|
||||
"details": ""
|
||||
}
|
||||
```
|
||||
|
||||
## Security Benefits
|
||||
|
||||
1. **Injection Prevention**: Validates inputs prevent command injection
|
||||
2. **Path Traversal Protection**: Path validation prevents directory traversal
|
||||
3. **Data Integrity**: Ensures data conforms to expected formats
|
||||
4. **System Stability**: Prevents invalid operations that could crash services
|
||||
5. **User Experience**: Clear error messages guide users to correct input
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Validate Early**: Validate inputs as soon as they're received
|
||||
2. **Sanitize Before Storage**: Sanitize strings before storing in database
|
||||
3. **Validate Format**: Check format before parsing (e.g., size strings)
|
||||
4. **Check Length**: Enforce maximum lengths to prevent DoS
|
||||
5. **Whitelist Characters**: Only allow known-safe characters
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Custom Validators**: Domain-specific validation rules
|
||||
2. **Validation Middleware**: Automatic validation for all endpoints
|
||||
3. **Schema Validation**: JSON schema validation
|
||||
4. **Rate Limiting**: Prevent abuse through validation
|
||||
5. **Input Normalization**: Automatic normalization of valid inputs
|
||||
306
docs/ZFS_OPERATIONS.md
Normal file
306
docs/ZFS_OPERATIONS.md
Normal file
@@ -0,0 +1,306 @@
|
||||
# ZFS Operations
|
||||
|
||||
## Overview
|
||||
|
||||
AtlasOS provides comprehensive ZFS pool management including pool creation, import, export, scrubbing with progress monitoring, and health status reporting.
|
||||
|
||||
## Pool Operations
|
||||
|
||||
### List Pools
|
||||
|
||||
**GET** `/api/v1/pools`
|
||||
|
||||
Returns all ZFS pools.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "tank",
|
||||
"status": "ONLINE",
|
||||
"size": 1099511627776,
|
||||
"allocated": 536870912000,
|
||||
"free": 562641027776,
|
||||
"health": "ONLINE",
|
||||
"created_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Get Pool
|
||||
|
||||
**GET** `/api/v1/pools/{name}`
|
||||
|
||||
Returns details for a specific pool.
|
||||
|
||||
### Create Pool
|
||||
|
||||
**POST** `/api/v1/pools`
|
||||
|
||||
Creates a new ZFS pool.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"name": "tank",
|
||||
"vdevs": ["sda", "sdb"],
|
||||
"options": {
|
||||
"ashift": "12"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Destroy Pool
|
||||
|
||||
**DELETE** `/api/v1/pools/{name}`
|
||||
|
||||
Destroys a ZFS pool. **Warning**: This is a destructive operation.
|
||||
|
||||
## Pool Import/Export
|
||||
|
||||
### List Available Pools
|
||||
|
||||
**GET** `/api/v1/pools/available`
|
||||
|
||||
Lists pools that can be imported (pools that exist but are not currently imported).
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"pools": ["tank", "backup"]
|
||||
}
|
||||
```
|
||||
|
||||
### Import Pool
|
||||
|
||||
**POST** `/api/v1/pools/import`
|
||||
|
||||
Imports a ZFS pool.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"name": "tank",
|
||||
"options": {
|
||||
"readonly": "on"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Options:**
|
||||
- `readonly`: Set pool to read-only mode (`on`/`off`)
|
||||
- Other ZFS pool properties
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"message": "pool imported",
|
||||
"name": "tank"
|
||||
}
|
||||
```
|
||||
|
||||
### Export Pool
|
||||
|
||||
**POST** `/api/v1/pools/{name}/export`
|
||||
|
||||
Exports a ZFS pool (makes it unavailable but preserves data).
|
||||
|
||||
**Request Body (optional):**
|
||||
```json
|
||||
{
|
||||
"force": false
|
||||
}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `force` (boolean): Force export even if pool is in use
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"message": "pool exported",
|
||||
"name": "tank"
|
||||
}
|
||||
```
|
||||
|
||||
## Scrub Operations
|
||||
|
||||
### Start Scrub
|
||||
|
||||
**POST** `/api/v1/pools/{name}/scrub`
|
||||
|
||||
Starts a scrub operation on a pool. Scrub verifies data integrity and repairs any errors found.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"message": "scrub started",
|
||||
"pool": "tank"
|
||||
}
|
||||
```
|
||||
|
||||
### Get Scrub Status
|
||||
|
||||
**GET** `/api/v1/pools/{name}/scrub`
|
||||
|
||||
Returns detailed scrub status with progress information.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "in_progress",
|
||||
"progress": 45.2,
|
||||
"time_elapsed": "2h15m",
|
||||
"time_remain": "30m",
|
||||
"speed": "100M/s",
|
||||
"errors": 0,
|
||||
"repaired": 0,
|
||||
"last_scrub": "2024-12-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Status Values:**
|
||||
- `idle`: No scrub in progress
|
||||
- `in_progress`: Scrub is currently running
|
||||
- `completed`: Scrub completed successfully
|
||||
- `error`: Scrub encountered errors
|
||||
|
||||
**Progress Fields:**
|
||||
- `progress`: Percentage complete (0-100)
|
||||
- `time_elapsed`: Time since scrub started
|
||||
- `time_remain`: Estimated time remaining
|
||||
- `speed`: Current scrub speed
|
||||
- `errors`: Number of errors found
|
||||
- `repaired`: Number of errors repaired
|
||||
- `last_scrub`: Timestamp of last completed scrub
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Import a Pool
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/pools/import \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "tank"
|
||||
}'
|
||||
```
|
||||
|
||||
### Start Scrub and Monitor Progress
|
||||
|
||||
```bash
|
||||
# Start scrub
|
||||
curl -X POST http://localhost:8080/api/v1/pools/tank/scrub \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# Check progress
|
||||
curl http://localhost:8080/api/v1/pools/tank/scrub \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
### Export Pool
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/pools/tank/export \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"force": false
|
||||
}'
|
||||
```
|
||||
|
||||
## Scrub Best Practices
|
||||
|
||||
### When to Scrub
|
||||
|
||||
- **Regular Schedule**: Monthly or quarterly
|
||||
- **After Disk Failures**: After replacing failed disks
|
||||
- **Before Major Operations**: Before pool upgrades or migrations
|
||||
- **After Data Corruption**: If data integrity issues are suspected
|
||||
|
||||
### Monitoring Scrub Progress
|
||||
|
||||
1. **Start Scrub**: Use POST endpoint to start
|
||||
2. **Monitor Progress**: Poll GET endpoint every few minutes
|
||||
3. **Check Errors**: Monitor `errors` and `repaired` fields
|
||||
4. **Wait for Completion**: Wait until `status` is `completed`
|
||||
|
||||
### Scrub Performance
|
||||
|
||||
- **Impact**: Scrub operations can impact pool performance
|
||||
- **Scheduling**: Schedule during low-usage periods
|
||||
- **Duration**: Large pools may take hours or days
|
||||
- **I/O**: Scrub generates significant I/O load
|
||||
|
||||
## Pool Import/Export Use Cases
|
||||
|
||||
### Import Use Cases
|
||||
|
||||
1. **System Reboot**: Pools are automatically imported on boot
|
||||
2. **Manual Import**: Import pools that were exported
|
||||
3. **Read-Only Import**: Import pool in read-only mode for inspection
|
||||
4. **Recovery**: Import pools from backup systems
|
||||
|
||||
### Export Use Cases
|
||||
|
||||
1. **System Shutdown**: Export pools before shutdown
|
||||
2. **Maintenance**: Export pools for maintenance operations
|
||||
3. **Migration**: Export pools before moving to another system
|
||||
4. **Backup**: Export pools before creating full backups
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Pool Not Found
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "NOT_FOUND",
|
||||
"message": "pool not found"
|
||||
}
|
||||
```
|
||||
|
||||
### Scrub Already Running
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "CONFLICT",
|
||||
"message": "scrub already in progress"
|
||||
}
|
||||
```
|
||||
|
||||
### Pool in Use (Export)
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "CONFLICT",
|
||||
"message": "pool is in use, cannot export"
|
||||
}
|
||||
```
|
||||
|
||||
Use `force: true` to force export (use with caution).
|
||||
|
||||
## Compliance with SRS
|
||||
|
||||
Per SRS section 4.2 ZFS Management:
|
||||
|
||||
- ✅ **List available disks**: Implemented
|
||||
- ✅ **Create pools**: Implemented
|
||||
- ✅ **Import pools**: Implemented (Priority 20)
|
||||
- ✅ **Export pools**: Implemented (Priority 20)
|
||||
- ✅ **Report pool health**: Implemented
|
||||
- ✅ **Create and manage datasets**: Implemented
|
||||
- ✅ **Create ZVOLs**: Implemented
|
||||
- ✅ **Scrub operations**: Implemented
|
||||
- ✅ **Progress monitoring**: Implemented (Priority 19)
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Scrub Scheduling**: Automatic scheduled scrubs
|
||||
2. **Scrub Notifications**: Alerts when scrub completes or finds errors
|
||||
3. **Pool Health Alerts**: Automatic alerts for pool health issues
|
||||
4. **Import History**: Track pool import/export history
|
||||
5. **Pool Properties**: Manage pool properties via API
|
||||
6. **VDEV Management**: Add/remove vdevs from pools
|
||||
7. **Pool Upgrade**: Upgrade pool version
|
||||
8. **Resilver Operations**: Monitor and manage resilver operations
|
||||
1866
docs/openapi.yaml
Normal file
1866
docs/openapi.yaml
Normal file
File diff suppressed because it is too large
Load Diff
34
fix-sudoers.sh
Executable file
34
fix-sudoers.sh
Executable file
@@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
# Quick fix script to add current user to ZFS sudoers for development
|
||||
# Usage: sudo ./fix-sudoers.sh
|
||||
|
||||
set -e
|
||||
|
||||
CURRENT_USER=$(whoami)
|
||||
SUDOERS_FILE="/etc/sudoers.d/atlas-zfs"
|
||||
|
||||
echo "Adding $CURRENT_USER to ZFS sudoers for development..."
|
||||
|
||||
# Check if sudoers file exists
|
||||
if [ ! -f "$SUDOERS_FILE" ]; then
|
||||
echo "Creating sudoers file..."
|
||||
cat > "$SUDOERS_FILE" <<EOF
|
||||
# Allow ZFS commands without password for development
|
||||
# This file is auto-generated - modify with caution
|
||||
EOF
|
||||
chmod 440 "$SUDOERS_FILE"
|
||||
fi
|
||||
|
||||
# Check if user is already in the file
|
||||
if grep -q "^$CURRENT_USER" "$SUDOERS_FILE"; then
|
||||
echo "User $CURRENT_USER already has ZFS sudoers access"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Add user to sudoers file
|
||||
cat >> "$SUDOERS_FILE" <<EOF
|
||||
$CURRENT_USER ALL=(ALL) NOPASSWD: /usr/sbin/zpool, /usr/bin/zpool, /sbin/zpool, /usr/sbin/zfs, /usr/bin/zfs, /sbin/zfs
|
||||
EOF
|
||||
|
||||
echo "Added $CURRENT_USER to ZFS sudoers"
|
||||
echo "You can now run atlas-api without sudo password prompts"
|
||||
21
go.mod
21
go.mod
@@ -1,3 +1,22 @@
|
||||
module example.com/atlasos
|
||||
module gitea.avt.data-center.id/othman.suseno/atlas
|
||||
|
||||
go 1.24.4
|
||||
|
||||
require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
golang.org/x/crypto v0.46.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
modernc.org/libc v1.66.10 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.40.1 // indirect
|
||||
)
|
||||
|
||||
27
go.sum
Normal file
27
go.sum
Normal file
@@ -0,0 +1,27 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
|
||||
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
||||
51
installer/README.md
Normal file
51
installer/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# AtlasOS Installer
|
||||
|
||||
This directory contains installation scripts for AtlasOS on Ubuntu 24.04.
|
||||
|
||||
## Files
|
||||
|
||||
- **`install.sh`** - Main installation script
|
||||
- **`bundle-downloader.sh`** - Downloads all packages for airgap installation
|
||||
- **`README.md`** - This file
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Standard Installation (with internet)
|
||||
|
||||
```bash
|
||||
# From repository root
|
||||
sudo ./installer/install.sh
|
||||
```
|
||||
|
||||
### Airgap Installation (offline)
|
||||
|
||||
**Step 1: Download bundle (on internet-connected system)**
|
||||
```bash
|
||||
sudo ./installer/bundle-downloader.sh ./atlas-bundle
|
||||
```
|
||||
|
||||
**Step 2: Transfer bundle to airgap system**
|
||||
|
||||
**Step 3: Install on airgap system**
|
||||
```bash
|
||||
sudo ./installer/install.sh --offline-bundle /path/to/atlas-bundle
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
See help for all options:
|
||||
```bash
|
||||
sudo ./installer/install.sh --help
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- **Installation Guide**: `../docs/INSTALLATION.md`
|
||||
- **Airgap Installation**: `../docs/AIRGAP_INSTALLATION.md`
|
||||
|
||||
## Requirements
|
||||
|
||||
- Ubuntu 24.04 (Noble Numbat)
|
||||
- Root/sudo access
|
||||
- Internet connection (for standard installation)
|
||||
- Or offline bundle (for airgap installation)
|
||||
223
installer/bundle-downloader.sh
Executable file
223
installer/bundle-downloader.sh
Executable file
@@ -0,0 +1,223 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# AtlasOS Bundle Downloader for Ubuntu 24.04
|
||||
# Downloads all required packages and dependencies for airgap installation
|
||||
#
|
||||
# Usage: sudo ./installer/bundle-downloader.sh [output-dir]
|
||||
# or: sudo ./bundle-downloader.sh [output-dir] (if run from installer/ directory)
|
||||
#
|
||||
# This script must be run on a system with internet access and Ubuntu 24.04
|
||||
# The downloaded packages can then be transferred to airgap systems
|
||||
#
|
||||
# Example:
|
||||
# sudo ./installer/bundle-downloader.sh ./atlas-bundle
|
||||
# # Transfer ./atlas-bundle to airgap system
|
||||
# # On airgap: sudo ./installer/install.sh --offline-bundle ./atlas-bundle
|
||||
#
|
||||
|
||||
set -e
|
||||
set -o pipefail
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Default output directory
|
||||
OUTPUT_DIR="${1:-./atlas-bundle-ubuntu24.04}"
|
||||
OUTPUT_DIR=$(realpath "$OUTPUT_DIR")
|
||||
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN}AtlasOS Bundle Downloader${NC}"
|
||||
echo -e "${GREEN}For Ubuntu 24.04 (Noble Numbat)${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo ""
|
||||
|
||||
# Check if running on Ubuntu 24.04
|
||||
if [[ ! -f /etc/os-release ]]; then
|
||||
echo -e "${RED}Error: Cannot detect Linux distribution${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
. /etc/os-release
|
||||
|
||||
if [[ "$ID" != "ubuntu" ]] || [[ "$VERSION_ID" != "24.04" ]]; then
|
||||
echo -e "${YELLOW}Warning: This script is designed for Ubuntu 24.04${NC}"
|
||||
echo " Detected: $ID $VERSION_ID"
|
||||
read -p "Continue anyway? (y/n) " -n 1 -r
|
||||
echo ""
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if running as root (needed for apt operations)
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo -e "${RED}Error: This script must be run as root (use sudo)${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create output directory
|
||||
echo "Creating output directory: $OUTPUT_DIR"
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
# Update package lists
|
||||
echo "Updating package lists..."
|
||||
apt-get update -qq
|
||||
|
||||
# List of packages to download (all dependencies will be included)
|
||||
PACKAGES=(
|
||||
# Build essentials
|
||||
"build-essential"
|
||||
"git"
|
||||
"curl"
|
||||
"wget"
|
||||
"ca-certificates"
|
||||
"software-properties-common"
|
||||
"apt-transport-https"
|
||||
|
||||
# ZFS utilities
|
||||
"zfsutils-linux"
|
||||
"zfs-zed"
|
||||
"zfs-initramfs"
|
||||
|
||||
# Storage services
|
||||
"samba"
|
||||
"samba-common-bin"
|
||||
"nfs-kernel-server"
|
||||
"rpcbind"
|
||||
|
||||
# iSCSI target
|
||||
"targetcli-fb"
|
||||
|
||||
# Database
|
||||
"sqlite3"
|
||||
"libsqlite3-dev"
|
||||
|
||||
# Go compiler
|
||||
"golang-go"
|
||||
|
||||
# Additional utilities
|
||||
"openssl"
|
||||
"net-tools"
|
||||
"iproute2"
|
||||
)
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}Downloading packages and all dependencies...${NC}"
|
||||
echo ""
|
||||
|
||||
# Download all packages with dependencies
|
||||
cd "$OUTPUT_DIR"
|
||||
|
||||
# Use apt-get download to get all packages including dependencies
|
||||
for pkg in "${PACKAGES[@]}"; do
|
||||
echo -n " Downloading $pkg... "
|
||||
if apt-get download "$pkg" 2>/dev/null; then
|
||||
echo -e "${GREEN}✓${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠ (may not be available)${NC}"
|
||||
fi
|
||||
done
|
||||
|
||||
# Download all dependencies recursively
|
||||
echo ""
|
||||
echo "Downloading all dependencies..."
|
||||
apt-get download $(apt-cache depends --recurse --no-recommends --no-suggests --no-conflicts --no-breaks --no-replaces --no-enhances "${PACKAGES[@]}" | grep "^\w" | sort -u) 2>/dev/null || {
|
||||
echo -e "${YELLOW}Warning: Some dependencies may have been skipped${NC}"
|
||||
}
|
||||
|
||||
# Download Go binary (if golang-go package is not sufficient)
|
||||
echo ""
|
||||
echo "Downloading Go binary (fallback)..."
|
||||
GO_VERSION="1.22.0"
|
||||
if ! wget -q "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" -O "$OUTPUT_DIR/go.tar.gz" 2>/dev/null; then
|
||||
echo -e "${YELLOW}Warning: Could not download Go binary${NC}"
|
||||
echo " You may need to download it manually from https://go.dev/dl/"
|
||||
fi
|
||||
|
||||
# Create manifest file
|
||||
echo ""
|
||||
echo "Creating manifest..."
|
||||
cat > "$OUTPUT_DIR/MANIFEST.txt" <<EOF
|
||||
AtlasOS Bundle for Ubuntu 24.04 (Noble Numbat)
|
||||
Generated: $(date -u +"%Y-%m-%d %H:%M:%S UTC")
|
||||
Packages: ${#PACKAGES[@]} main packages + dependencies
|
||||
|
||||
Main Packages:
|
||||
$(printf '%s\n' "${PACKAGES[@]}")
|
||||
|
||||
Total .deb files: $(find "$OUTPUT_DIR" -name "*.deb" | wc -l)
|
||||
|
||||
Installation Instructions:
|
||||
1. Transfer this entire directory to your airgap system
|
||||
2. Run: sudo ./installer/install.sh --offline-bundle "$OUTPUT_DIR"
|
||||
|
||||
Note: Ensure all .deb files are present before transferring
|
||||
EOF
|
||||
|
||||
# Create README
|
||||
cat > "$OUTPUT_DIR/README.md" <<'EOF'
|
||||
# AtlasOS Offline Bundle for Ubuntu 24.04
|
||||
|
||||
This bundle contains all required packages and dependencies for installing AtlasOS on an airgap (offline) Ubuntu 24.04 system.
|
||||
|
||||
## Contents
|
||||
|
||||
- All required .deb packages with dependencies
|
||||
- Go binary (fallback, if needed)
|
||||
- Installation manifest
|
||||
|
||||
## Usage
|
||||
|
||||
1. Transfer this entire directory to your airgap system
|
||||
2. On the airgap system, run:
|
||||
```bash
|
||||
sudo ./installer/install.sh --offline-bundle /path/to/this/directory
|
||||
```
|
||||
|
||||
## Bundle Size
|
||||
|
||||
The bundle typically contains:
|
||||
- ~100-200 .deb packages (including dependencies)
|
||||
- Total size: ~500MB - 1GB (depending on architecture)
|
||||
|
||||
## Verification
|
||||
|
||||
Before transferring, verify the bundle:
|
||||
```bash
|
||||
# Count .deb files
|
||||
find . -name "*.deb" | wc -l
|
||||
|
||||
# Check manifest
|
||||
cat MANIFEST.txt
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If installation fails:
|
||||
1. Check that all .deb files are present
|
||||
2. Verify you're on Ubuntu 24.04
|
||||
3. Check disk space (need at least 2GB free)
|
||||
4. Review installation logs
|
||||
EOF
|
||||
|
||||
# Summary
|
||||
echo ""
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN}Bundle Download Complete!${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo ""
|
||||
echo "Output directory: $OUTPUT_DIR"
|
||||
echo "Total .deb files: $(find "$OUTPUT_DIR" -name "*.deb" | wc -l)"
|
||||
echo "Total size: $(du -sh "$OUTPUT_DIR" | cut -f1)"
|
||||
echo ""
|
||||
echo "Manifest: $OUTPUT_DIR/MANIFEST.txt"
|
||||
echo "README: $OUTPUT_DIR/README.md"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Next Steps:${NC}"
|
||||
echo "1. Transfer this directory to your airgap system"
|
||||
echo "2. On airgap system, run:"
|
||||
echo " sudo ./installer/install.sh --offline-bundle \"$OUTPUT_DIR\""
|
||||
echo ""
|
||||
1281
installer/install.sh
Executable file
1281
installer/install.sh
Executable file
File diff suppressed because it is too large
Load Diff
106
internal/audit/store.go
Normal file
106
internal/audit/store.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/models"
|
||||
)
|
||||
|
||||
// Store manages audit logs
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
logs []models.AuditLog
|
||||
nextID int64
|
||||
maxLogs int // Maximum number of logs to keep (0 = unlimited)
|
||||
}
|
||||
|
||||
// NewStore creates a new audit log store
|
||||
func NewStore(maxLogs int) *Store {
|
||||
return &Store{
|
||||
logs: make([]models.AuditLog, 0),
|
||||
nextID: 1,
|
||||
maxLogs: maxLogs,
|
||||
}
|
||||
}
|
||||
|
||||
// Log records an audit log entry
|
||||
func (s *Store) Log(actor, action, resource, result, message, ip, userAgent string) *models.AuditLog {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
id := fmt.Sprintf("audit-%d", s.nextID)
|
||||
s.nextID++
|
||||
|
||||
entry := models.AuditLog{
|
||||
ID: id,
|
||||
Actor: actor,
|
||||
Action: action,
|
||||
Resource: resource,
|
||||
Result: result,
|
||||
Message: message,
|
||||
IP: ip,
|
||||
UserAgent: userAgent,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
s.logs = append(s.logs, entry)
|
||||
|
||||
// Enforce max logs limit
|
||||
if s.maxLogs > 0 && len(s.logs) > s.maxLogs {
|
||||
// Remove oldest logs
|
||||
excess := len(s.logs) - s.maxLogs
|
||||
s.logs = s.logs[excess:]
|
||||
}
|
||||
|
||||
return &entry
|
||||
}
|
||||
|
||||
// List returns audit logs, optionally filtered
|
||||
func (s *Store) List(actor, action, resource string, limit int) []models.AuditLog {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var filtered []models.AuditLog
|
||||
for i := len(s.logs) - 1; i >= 0; i-- { // Reverse iteration (newest first)
|
||||
log := s.logs[i]
|
||||
|
||||
if actor != "" && log.Actor != actor {
|
||||
continue
|
||||
}
|
||||
if action != "" && log.Action != action {
|
||||
continue
|
||||
}
|
||||
if resource != "" && !containsResource(log.Resource, resource) {
|
||||
continue
|
||||
}
|
||||
|
||||
filtered = append(filtered, log)
|
||||
if limit > 0 && len(filtered) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
// Get returns a specific audit log by ID
|
||||
func (s *Store) Get(id string) (*models.AuditLog, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
for _, log := range s.logs {
|
||||
if log.ID == id {
|
||||
return &log, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("audit log %s not found", id)
|
||||
}
|
||||
|
||||
// containsResource checks if resource string contains the search term
|
||||
func containsResource(resource, search string) bool {
|
||||
return resource == search ||
|
||||
(len(resource) > len(search) && resource[:len(search)] == search)
|
||||
}
|
||||
64
internal/auth/jwt.go
Normal file
64
internal/auth/jwt.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidToken = errors.New("invalid token")
|
||||
ErrExpiredToken = errors.New("token expired")
|
||||
)
|
||||
|
||||
// Claims represents JWT claims
|
||||
type Claims struct {
|
||||
UserID string `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// GenerateToken generates a JWT token for a user
|
||||
func (s *Service) GenerateToken(userID, role string) (string, error) {
|
||||
expirationTime := time.Now().Add(24 * time.Hour) // Token valid for 24 hours
|
||||
|
||||
claims := &Claims{
|
||||
UserID: userID,
|
||||
Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(expirationTime),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(s.jwtSecret)
|
||||
}
|
||||
|
||||
// ValidateToken validates a JWT token and returns the claims
|
||||
func (s *Service) ValidateToken(tokenString string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("unexpected signing method")
|
||||
}
|
||||
return s.jwtSecret, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
// Check if token is expired
|
||||
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||
return nil, ErrExpiredToken
|
||||
}
|
||||
// All other errors are invalid tokens
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
51
internal/auth/service.go
Normal file
51
internal/auth/service.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Service provides authentication operations
|
||||
type Service struct {
|
||||
jwtSecret []byte
|
||||
}
|
||||
|
||||
// New creates a new auth service
|
||||
func New(secret string) *Service {
|
||||
if secret == "" {
|
||||
// Generate a random secret if not provided (not recommended for production)
|
||||
secret = generateSecret()
|
||||
}
|
||||
return &Service{
|
||||
jwtSecret: []byte(secret),
|
||||
}
|
||||
}
|
||||
|
||||
// HashPassword hashes a password using bcrypt
|
||||
func (s *Service) HashPassword(password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
// VerifyPassword verifies a password against a hash
|
||||
func (s *Service) VerifyPassword(hashedPassword, password string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// generateSecret generates a random secret for JWT signing
|
||||
func generateSecret() string {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
return base64.URLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// GetSecret returns the JWT secret
|
||||
func (s *Service) GetSecret() []byte {
|
||||
return s.jwtSecret
|
||||
}
|
||||
215
internal/auth/user_store.go
Normal file
215
internal/auth/user_store.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/models"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrUserExists = errors.New("user already exists")
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
)
|
||||
|
||||
// UserStore manages users in memory
|
||||
type UserStore struct {
|
||||
mu sync.RWMutex
|
||||
users map[string]*models.User
|
||||
nextID int64
|
||||
auth *Service
|
||||
}
|
||||
|
||||
// NewUserStore creates a new user store
|
||||
func NewUserStore(auth *Service) *UserStore {
|
||||
store := &UserStore{
|
||||
users: make(map[string]*models.User),
|
||||
nextID: 1,
|
||||
auth: auth,
|
||||
}
|
||||
|
||||
// Create default admin user if no users exist
|
||||
store.createDefaultAdmin()
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
// createDefaultAdmin creates a default administrator user
|
||||
func (s *UserStore) createDefaultAdmin() {
|
||||
// Check if any users exist
|
||||
s.mu.RLock()
|
||||
hasUsers := len(s.users) > 0
|
||||
s.mu.RUnlock()
|
||||
|
||||
if hasUsers {
|
||||
return
|
||||
}
|
||||
|
||||
// Create default admin: admin / admin (should be changed on first login)
|
||||
hashedPassword, _ := s.auth.HashPassword("admin")
|
||||
admin := &models.User{
|
||||
ID: "user-1",
|
||||
Username: "admin",
|
||||
Role: models.RoleAdministrator,
|
||||
Active: true,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Store password hash (in production, this would be in a separate secure store)
|
||||
s.mu.Lock()
|
||||
s.users[admin.ID] = admin
|
||||
s.nextID = 2
|
||||
s.mu.Unlock()
|
||||
|
||||
// Store password hash separately (in production, use proper user model with password field)
|
||||
_ = hashedPassword // TODO: Store in user model or separate secure store
|
||||
}
|
||||
|
||||
// Create creates a new user
|
||||
func (s *UserStore) Create(username, email, password string, role models.Role) (*models.User, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Check if username already exists
|
||||
for _, user := range s.users {
|
||||
if user.Username == username {
|
||||
return nil, ErrUserExists
|
||||
}
|
||||
}
|
||||
|
||||
id := fmt.Sprintf("user-%d", s.nextID)
|
||||
s.nextID++
|
||||
|
||||
hashedPassword, err := s.auth.HashPassword(password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := &models.User{
|
||||
ID: id,
|
||||
Username: username,
|
||||
Email: email,
|
||||
Role: role,
|
||||
Active: true,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
s.users[user.ID] = user
|
||||
_ = hashedPassword // TODO: Store password hash
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GetByID returns a user by ID
|
||||
func (s *UserStore) GetByID(id string) (*models.User, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
user, exists := s.users[id]
|
||||
if !exists {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GetByUsername returns a user by username
|
||||
func (s *UserStore) GetByUsername(username string) (*models.User, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
for _, user := range s.users {
|
||||
if user.Username == username {
|
||||
return user, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
|
||||
// Authenticate verifies username and password
|
||||
func (s *UserStore) Authenticate(username, password string) (*models.User, error) {
|
||||
user, err := s.GetByUsername(username)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
if !user.Active {
|
||||
return nil, errors.New("user account is disabled")
|
||||
}
|
||||
|
||||
// TODO: Verify password against stored hash
|
||||
// For now, accept "admin" password for default admin
|
||||
if username == "admin" && password == "admin" {
|
||||
return user, nil
|
||||
}
|
||||
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
// List returns all users
|
||||
func (s *UserStore) List() []models.User {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
users := make([]models.User, 0, len(s.users))
|
||||
for _, user := range s.users {
|
||||
users = append(users, *user)
|
||||
}
|
||||
return users
|
||||
}
|
||||
|
||||
// Update updates a user
|
||||
func (s *UserStore) Update(id string, email string, role models.Role, active bool) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
user, exists := s.users[id]
|
||||
if !exists {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
|
||||
user.Email = email
|
||||
user.Role = role
|
||||
user.Active = active
|
||||
user.UpdatedAt = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete deletes a user
|
||||
func (s *UserStore) Delete(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, exists := s.users[id]; !exists {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
|
||||
delete(s.users, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdatePassword updates a user's password
|
||||
func (s *UserStore) UpdatePassword(id, newPassword string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
user, exists := s.users[id]
|
||||
if !exists {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
|
||||
hashedPassword, err := s.auth.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = hashedPassword // TODO: Store password hash
|
||||
user.UpdatedAt = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
350
internal/backup/service.go
Normal file
350
internal/backup/service.go
Normal file
@@ -0,0 +1,350 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/models"
|
||||
)
|
||||
|
||||
// Service handles configuration backup and restore operations
|
||||
type Service struct {
|
||||
backupDir string
|
||||
}
|
||||
|
||||
// BackupMetadata contains information about a backup
|
||||
type BackupMetadata struct {
|
||||
ID string `json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Size int64 `json:"size"`
|
||||
Checksum string `json:"checksum,omitempty"`
|
||||
}
|
||||
|
||||
// BackupData contains all configuration data to be backed up
|
||||
type BackupData struct {
|
||||
Metadata BackupMetadata `json:"metadata"`
|
||||
Users []models.User `json:"users,omitempty"`
|
||||
SMBShares []models.SMBShare `json:"smb_shares,omitempty"`
|
||||
NFSExports []models.NFSExport `json:"nfs_exports,omitempty"`
|
||||
ISCSITargets []models.ISCSITarget `json:"iscsi_targets,omitempty"`
|
||||
Policies []models.SnapshotPolicy `json:"policies,omitempty"`
|
||||
Config map[string]interface{} `json:"config,omitempty"`
|
||||
}
|
||||
|
||||
// New creates a new backup service
|
||||
func New(backupDir string) (*Service, error) {
|
||||
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("create backup directory: %w", err)
|
||||
}
|
||||
|
||||
return &Service{
|
||||
backupDir: backupDir,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateBackup creates a backup of all system configurations
|
||||
func (s *Service) CreateBackup(data BackupData, description string) (string, error) {
|
||||
// Generate backup ID
|
||||
backupID := fmt.Sprintf("backup-%d", time.Now().Unix())
|
||||
backupPath := filepath.Join(s.backupDir, backupID+".tar.gz")
|
||||
|
||||
// Set metadata
|
||||
data.Metadata.ID = backupID
|
||||
data.Metadata.CreatedAt = time.Now()
|
||||
data.Metadata.Version = "1.0"
|
||||
data.Metadata.Description = description
|
||||
|
||||
// Create backup file
|
||||
file, err := os.Create(backupPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create backup file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Create gzip writer
|
||||
gzWriter := gzip.NewWriter(file)
|
||||
defer gzWriter.Close()
|
||||
|
||||
// Create tar writer
|
||||
tarWriter := tar.NewWriter(gzWriter)
|
||||
defer tarWriter.Close()
|
||||
|
||||
// Write metadata
|
||||
metadataJSON, err := json.MarshalIndent(data.Metadata, "", " ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal metadata: %w", err)
|
||||
}
|
||||
|
||||
if err := s.writeFileToTar(tarWriter, "metadata.json", metadataJSON); err != nil {
|
||||
return "", fmt.Errorf("write metadata: %w", err)
|
||||
}
|
||||
|
||||
// Write configuration data
|
||||
configJSON, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
|
||||
if err := s.writeFileToTar(tarWriter, "config.json", configJSON); err != nil {
|
||||
return "", fmt.Errorf("write config: %w", err)
|
||||
}
|
||||
|
||||
// Get file size
|
||||
stat, err := file.Stat()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get file stat: %w", err)
|
||||
}
|
||||
|
||||
data.Metadata.Size = stat.Size()
|
||||
|
||||
// Update metadata with size
|
||||
metadataJSON, err = json.MarshalIndent(data.Metadata, "", " ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal updated metadata: %w", err)
|
||||
}
|
||||
|
||||
// Note: We can't update the tar file, so we'll store metadata separately
|
||||
metadataPath := filepath.Join(s.backupDir, backupID+".meta.json")
|
||||
if err := os.WriteFile(metadataPath, metadataJSON, 0644); err != nil {
|
||||
return "", fmt.Errorf("write metadata file: %w", err)
|
||||
}
|
||||
|
||||
return backupID, nil
|
||||
}
|
||||
|
||||
// writeFileToTar writes a file to a tar archive
|
||||
func (s *Service) writeFileToTar(tw *tar.Writer, filename string, data []byte) error {
|
||||
header := &tar.Header{
|
||||
Name: filename,
|
||||
Size: int64(len(data)),
|
||||
Mode: 0644,
|
||||
ModTime: time.Now(),
|
||||
}
|
||||
|
||||
if err := tw.WriteHeader(header); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tw.Write(data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListBackups returns a list of all available backups
|
||||
func (s *Service) ListBackups() ([]BackupMetadata, error) {
|
||||
files, err := os.ReadDir(s.backupDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read backup directory: %w", err)
|
||||
}
|
||||
|
||||
var backups []BackupMetadata
|
||||
for _, file := range files {
|
||||
if file.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
if filepath.Ext(file.Name()) != ".json" || !strings.HasSuffix(file.Name(), ".meta.json") {
|
||||
continue
|
||||
}
|
||||
|
||||
metadataPath := filepath.Join(s.backupDir, file.Name())
|
||||
data, err := os.ReadFile(metadataPath)
|
||||
if err != nil {
|
||||
continue // Skip corrupted metadata files
|
||||
}
|
||||
|
||||
var metadata BackupMetadata
|
||||
if err := json.Unmarshal(data, &metadata); err != nil {
|
||||
continue // Skip invalid metadata files
|
||||
}
|
||||
|
||||
// Get actual backup file size if it exists
|
||||
backupPath := filepath.Join(s.backupDir, metadata.ID+".tar.gz")
|
||||
if stat, err := os.Stat(backupPath); err == nil {
|
||||
metadata.Size = stat.Size()
|
||||
}
|
||||
|
||||
backups = append(backups, metadata)
|
||||
}
|
||||
|
||||
return backups, nil
|
||||
}
|
||||
|
||||
// GetBackup returns metadata for a specific backup
|
||||
func (s *Service) GetBackup(backupID string) (*BackupMetadata, error) {
|
||||
metadataPath := filepath.Join(s.backupDir, backupID+".meta.json")
|
||||
data, err := os.ReadFile(metadataPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read metadata: %w", err)
|
||||
}
|
||||
|
||||
var metadata BackupMetadata
|
||||
if err := json.Unmarshal(data, &metadata); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal metadata: %w", err)
|
||||
}
|
||||
|
||||
// Get actual backup file size
|
||||
backupPath := filepath.Join(s.backupDir, backupID+".tar.gz")
|
||||
if stat, err := os.Stat(backupPath); err == nil {
|
||||
metadata.Size = stat.Size()
|
||||
}
|
||||
|
||||
return &metadata, nil
|
||||
}
|
||||
|
||||
// RestoreBackup restores configuration from a backup
|
||||
func (s *Service) RestoreBackup(backupID string) (*BackupData, error) {
|
||||
backupPath := filepath.Join(s.backupDir, backupID+".tar.gz")
|
||||
|
||||
file, err := os.Open(backupPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open backup file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Create gzip reader
|
||||
gzReader, err := gzip.NewReader(file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create gzip reader: %w", err)
|
||||
}
|
||||
defer gzReader.Close()
|
||||
|
||||
// Create tar reader
|
||||
tarReader := tar.NewReader(gzReader)
|
||||
|
||||
var configData []byte
|
||||
var metadataData []byte
|
||||
|
||||
// Extract files from tar
|
||||
for {
|
||||
header, err := tarReader.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read tar: %w", err)
|
||||
}
|
||||
|
||||
switch header.Name {
|
||||
case "config.json":
|
||||
configData, err = io.ReadAll(tarReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
case "metadata.json":
|
||||
metadataData, err = io.ReadAll(tarReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read metadata: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if configData == nil {
|
||||
return nil, fmt.Errorf("config.json not found in backup")
|
||||
}
|
||||
|
||||
var backupData BackupData
|
||||
if err := json.Unmarshal(configData, &backupData); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal config: %w", err)
|
||||
}
|
||||
|
||||
// Update metadata if available
|
||||
if metadataData != nil {
|
||||
if err := json.Unmarshal(metadataData, &backupData.Metadata); err == nil {
|
||||
// Metadata loaded successfully
|
||||
}
|
||||
}
|
||||
|
||||
return &backupData, nil
|
||||
}
|
||||
|
||||
// DeleteBackup deletes a backup file and its metadata
|
||||
func (s *Service) DeleteBackup(backupID string) error {
|
||||
backupPath := filepath.Join(s.backupDir, backupID+".tar.gz")
|
||||
metadataPath := filepath.Join(s.backupDir, backupID+".meta.json")
|
||||
|
||||
var errors []error
|
||||
|
||||
if err := os.Remove(backupPath); err != nil && !os.IsNotExist(err) {
|
||||
errors = append(errors, fmt.Errorf("remove backup file: %w", err))
|
||||
}
|
||||
|
||||
if err := os.Remove(metadataPath); err != nil && !os.IsNotExist(err) {
|
||||
errors = append(errors, fmt.Errorf("remove metadata file: %w", err))
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
return fmt.Errorf("delete backup: %v", errors)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyBackup verifies that a backup file is valid and can be restored
|
||||
func (s *Service) VerifyBackup(backupID string) error {
|
||||
backupPath := filepath.Join(s.backupDir, backupID+".tar.gz")
|
||||
|
||||
file, err := os.Open(backupPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open backup file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Try to read the backup
|
||||
gzReader, err := gzip.NewReader(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid gzip format: %w", err)
|
||||
}
|
||||
defer gzReader.Close()
|
||||
|
||||
tarReader := tar.NewReader(gzReader)
|
||||
|
||||
hasConfig := false
|
||||
|
||||
for {
|
||||
header, err := tarReader.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid tar format: %w", err)
|
||||
}
|
||||
|
||||
switch header.Name {
|
||||
case "config.json":
|
||||
hasConfig = true
|
||||
// Try to read and parse config
|
||||
data, err := io.ReadAll(tarReader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
var backupData BackupData
|
||||
if err := json.Unmarshal(data, &backupData); err != nil {
|
||||
return fmt.Errorf("invalid config format: %w", err)
|
||||
}
|
||||
case "metadata.json":
|
||||
// Metadata is optional, just verify it can be read
|
||||
_, err := io.ReadAll(tarReader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read metadata: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !hasConfig {
|
||||
return fmt.Errorf("backup missing config.json")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
173
internal/db/db.go
Normal file
173
internal/db/db.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// DB wraps a database connection
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
}
|
||||
|
||||
// New creates a new database connection
|
||||
func New(dbPath string) (*DB, error) {
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(dbPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("create db directory: %w", err)
|
||||
}
|
||||
|
||||
// Configure connection pool
|
||||
conn, err := sql.Open("sqlite", dbPath+"?_foreign_keys=1&_journal_mode=WAL")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
|
||||
// Set connection pool settings for better performance
|
||||
conn.SetMaxOpenConns(25) // Maximum number of open connections
|
||||
conn.SetMaxIdleConns(5) // Maximum number of idle connections
|
||||
conn.SetConnMaxLifetime(5 * time.Minute) // Maximum connection lifetime
|
||||
|
||||
db := &DB{DB: conn}
|
||||
|
||||
// Test connection
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("ping database: %w", err)
|
||||
}
|
||||
|
||||
// Run migrations
|
||||
if err := db.migrate(); err != nil {
|
||||
return nil, fmt.Errorf("migrate database: %w", err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// migrate runs database migrations
|
||||
func (db *DB) migrate() error {
|
||||
schema := `
|
||||
-- Users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
email TEXT,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Audit logs table
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id TEXT PRIMARY KEY,
|
||||
actor TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
resource TEXT NOT NULL,
|
||||
result TEXT NOT NULL,
|
||||
message TEXT,
|
||||
ip TEXT,
|
||||
user_agent TEXT,
|
||||
timestamp TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_logs(actor);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_logs(action);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_resource ON audit_logs(resource);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_logs(timestamp);
|
||||
|
||||
-- SMB shares table
|
||||
CREATE TABLE IF NOT EXISTS smb_shares (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
dataset TEXT NOT NULL,
|
||||
description TEXT,
|
||||
read_only INTEGER NOT NULL DEFAULT 0,
|
||||
guest_ok INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
-- SMB share valid users (many-to-many)
|
||||
CREATE TABLE IF NOT EXISTS smb_share_users (
|
||||
share_id TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
PRIMARY KEY (share_id, username),
|
||||
FOREIGN KEY (share_id) REFERENCES smb_shares(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- NFS exports table
|
||||
CREATE TABLE IF NOT EXISTS nfs_exports (
|
||||
id TEXT PRIMARY KEY,
|
||||
path TEXT UNIQUE NOT NULL,
|
||||
dataset TEXT NOT NULL,
|
||||
read_only INTEGER NOT NULL DEFAULT 0,
|
||||
root_squash INTEGER NOT NULL DEFAULT 1,
|
||||
enabled INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
-- NFS export clients (many-to-many)
|
||||
CREATE TABLE IF NOT EXISTS nfs_export_clients (
|
||||
export_id TEXT NOT NULL,
|
||||
client TEXT NOT NULL,
|
||||
PRIMARY KEY (export_id, client),
|
||||
FOREIGN KEY (export_id) REFERENCES nfs_exports(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- iSCSI targets table
|
||||
CREATE TABLE IF NOT EXISTS iscsi_targets (
|
||||
id TEXT PRIMARY KEY,
|
||||
iqn TEXT UNIQUE NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
-- iSCSI target initiators (many-to-many)
|
||||
CREATE TABLE IF NOT EXISTS iscsi_target_initiators (
|
||||
target_id TEXT NOT NULL,
|
||||
initiator TEXT NOT NULL,
|
||||
PRIMARY KEY (target_id, initiator),
|
||||
FOREIGN KEY (target_id) REFERENCES iscsi_targets(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- iSCSI LUNs table
|
||||
CREATE TABLE IF NOT EXISTS iscsi_luns (
|
||||
target_id TEXT NOT NULL,
|
||||
lun_id INTEGER NOT NULL,
|
||||
zvol TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
backend TEXT NOT NULL DEFAULT 'zvol',
|
||||
PRIMARY KEY (target_id, lun_id),
|
||||
FOREIGN KEY (target_id) REFERENCES iscsi_targets(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Snapshot policies table
|
||||
CREATE TABLE IF NOT EXISTS snapshot_policies (
|
||||
id TEXT PRIMARY KEY,
|
||||
dataset TEXT NOT NULL,
|
||||
schedule_type TEXT NOT NULL,
|
||||
schedule_value TEXT,
|
||||
retention_count INTEGER,
|
||||
retention_days INTEGER,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_snapshot_policy_dataset ON snapshot_policies(dataset);
|
||||
`
|
||||
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
return fmt.Errorf("create schema: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the database connection
|
||||
func (db *DB) Close() error {
|
||||
return db.DB.Close()
|
||||
}
|
||||
114
internal/errors/errors.go
Normal file
114
internal/errors/errors.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// ErrorCode represents a specific error type
|
||||
type ErrorCode string
|
||||
|
||||
const (
|
||||
ErrCodeInternal ErrorCode = "INTERNAL_ERROR"
|
||||
ErrCodeNotFound ErrorCode = "NOT_FOUND"
|
||||
ErrCodeBadRequest ErrorCode = "BAD_REQUEST"
|
||||
ErrCodeConflict ErrorCode = "CONFLICT"
|
||||
ErrCodeUnauthorized ErrorCode = "UNAUTHORIZED"
|
||||
ErrCodeForbidden ErrorCode = "FORBIDDEN"
|
||||
ErrCodeServiceUnavailable ErrorCode = "SERVICE_UNAVAILABLE"
|
||||
ErrCodeValidation ErrorCode = "VALIDATION_ERROR"
|
||||
)
|
||||
|
||||
// APIError represents a structured API error
|
||||
type APIError struct {
|
||||
Code ErrorCode `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details string `json:"details,omitempty"`
|
||||
HTTPStatus int `json:"-"`
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
if e.Details != "" {
|
||||
return fmt.Sprintf("%s: %s (%s)", e.Code, e.Message, e.Details)
|
||||
}
|
||||
return fmt.Sprintf("%s: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// NewAPIError creates a new API error
|
||||
func NewAPIError(code ErrorCode, message string, httpStatus int) *APIError {
|
||||
return &APIError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
HTTPStatus: httpStatus,
|
||||
}
|
||||
}
|
||||
|
||||
// WithDetails adds details to an error
|
||||
func (e *APIError) WithDetails(details string) *APIError {
|
||||
e.Details = details
|
||||
return e
|
||||
}
|
||||
|
||||
// Common error constructors
|
||||
func ErrNotFound(resource string) *APIError {
|
||||
return NewAPIError(ErrCodeNotFound, fmt.Sprintf("%s not found", resource), http.StatusNotFound)
|
||||
}
|
||||
|
||||
func ErrBadRequest(message string) *APIError {
|
||||
return NewAPIError(ErrCodeBadRequest, message, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func ErrConflict(message string) *APIError {
|
||||
return NewAPIError(ErrCodeConflict, message, http.StatusConflict)
|
||||
}
|
||||
|
||||
func ErrInternal(message string) *APIError {
|
||||
return NewAPIError(ErrCodeInternal, message, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func ErrServiceUnavailable(service string) *APIError {
|
||||
return NewAPIError(ErrCodeServiceUnavailable, fmt.Sprintf("%s service is unavailable", service), http.StatusServiceUnavailable)
|
||||
}
|
||||
|
||||
func ErrValidation(message string) *APIError {
|
||||
return NewAPIError(ErrCodeValidation, message, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// RetryConfig defines retry behavior
|
||||
type RetryConfig struct {
|
||||
MaxAttempts int
|
||||
Backoff func(attempt int) error // Returns error if should stop retrying
|
||||
}
|
||||
|
||||
// DefaultRetryConfig returns a default retry configuration
|
||||
func DefaultRetryConfig() RetryConfig {
|
||||
return RetryConfig{
|
||||
MaxAttempts: 3,
|
||||
Backoff: func(attempt int) error {
|
||||
// Simple exponential backoff: 100ms, 200ms, 400ms
|
||||
if attempt >= 3 {
|
||||
return fmt.Errorf("max attempts reached")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Retry executes a function with retry logic
|
||||
func Retry(fn func() error, config RetryConfig) error {
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= config.MaxAttempts; attempt++ {
|
||||
if err := fn(); err == nil {
|
||||
return nil
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
if attempt < config.MaxAttempts {
|
||||
if err := config.Backoff(attempt); err != nil {
|
||||
return fmt.Errorf("retry aborted: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("retry failed after %d attempts: %w", config.MaxAttempts, lastErr)
|
||||
}
|
||||
78
internal/errors/errors_test.go
Normal file
78
internal/errors/errors_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestErrNotFound(t *testing.T) {
|
||||
err := ErrNotFound("pool")
|
||||
if err.Code != ErrCodeNotFound {
|
||||
t.Errorf("expected code %s, got %s", ErrCodeNotFound, err.Code)
|
||||
}
|
||||
if err.HTTPStatus != http.StatusNotFound {
|
||||
t.Errorf("expected status %d, got %d", http.StatusNotFound, err.HTTPStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrBadRequest(t *testing.T) {
|
||||
err := ErrBadRequest("invalid request")
|
||||
if err.Code != ErrCodeBadRequest {
|
||||
t.Errorf("expected code %s, got %s", ErrCodeBadRequest, err.Code)
|
||||
}
|
||||
if err.HTTPStatus != http.StatusBadRequest {
|
||||
t.Errorf("expected status %d, got %d", http.StatusBadRequest, err.HTTPStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrValidation(t *testing.T) {
|
||||
err := ErrValidation("validation failed")
|
||||
if err.Code != ErrCodeValidation {
|
||||
t.Errorf("expected code %s, got %s", ErrCodeValidation, err.Code)
|
||||
}
|
||||
if err.HTTPStatus != http.StatusBadRequest {
|
||||
t.Errorf("expected status %d, got %d", http.StatusBadRequest, err.HTTPStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrInternal(t *testing.T) {
|
||||
err := ErrInternal("internal error")
|
||||
if err.Code != ErrCodeInternal {
|
||||
t.Errorf("expected code %s, got %s", ErrCodeInternal, err.Code)
|
||||
}
|
||||
if err.HTTPStatus != http.StatusInternalServerError {
|
||||
t.Errorf("expected status %d, got %d", http.StatusInternalServerError, err.HTTPStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrConflict(t *testing.T) {
|
||||
err := ErrConflict("resource exists")
|
||||
if err.Code != ErrCodeConflict {
|
||||
t.Errorf("expected code %s, got %s", ErrCodeConflict, err.Code)
|
||||
}
|
||||
if err.HTTPStatus != http.StatusConflict {
|
||||
t.Errorf("expected status %d, got %d", http.StatusConflict, err.HTTPStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithDetails(t *testing.T) {
|
||||
err := ErrNotFound("pool").WithDetails("tank")
|
||||
if err.Details != "tank" {
|
||||
t.Errorf("expected details 'tank', got %s", err.Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestError(t *testing.T) {
|
||||
err := ErrNotFound("pool")
|
||||
errorStr := err.Error()
|
||||
if errorStr == "" {
|
||||
t.Error("expected non-empty error string")
|
||||
}
|
||||
if err.Details != "" {
|
||||
errWithDetails := err.WithDetails("tank")
|
||||
errorStr = errWithDetails.Error()
|
||||
if errorStr == "" {
|
||||
t.Error("expected non-empty error string with details")
|
||||
}
|
||||
}
|
||||
}
|
||||
1490
internal/httpapp/api_handlers.go
Normal file
1490
internal/httpapp/api_handlers.go
Normal file
File diff suppressed because it is too large
Load Diff
272
internal/httpapp/app.go
Normal file
272
internal/httpapp/app.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package httpapp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/audit"
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/auth"
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/backup"
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/db"
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/job"
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/maintenance"
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/metrics"
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/services"
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/snapshot"
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/storage"
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/tls"
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/zfs"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Addr string
|
||||
TemplatesDir string
|
||||
StaticDir string
|
||||
DatabasePath string // Path to SQLite database (empty = in-memory mode)
|
||||
}
|
||||
|
||||
type App struct {
|
||||
cfg Config
|
||||
tmpl *template.Template
|
||||
mux *http.ServeMux
|
||||
zfs *zfs.Service
|
||||
snapshotPolicy *snapshot.PolicyStore
|
||||
jobManager *job.Manager
|
||||
scheduler *snapshot.Scheduler
|
||||
authService *auth.Service
|
||||
userStore *auth.UserStore
|
||||
auditStore *audit.Store
|
||||
smbStore *storage.SMBStore
|
||||
nfsStore *storage.NFSStore
|
||||
iscsiStore *storage.ISCSIStore
|
||||
database *db.DB // Optional database connection
|
||||
smbService *services.SMBService
|
||||
nfsService *services.NFSService
|
||||
iscsiService *services.ISCSIService
|
||||
metricsCollector *metrics.Collector
|
||||
startTime time.Time
|
||||
backupService *backup.Service
|
||||
maintenanceService *maintenance.Service
|
||||
tlsConfig *tls.Config
|
||||
cache *ResponseCache
|
||||
}
|
||||
|
||||
func New(cfg Config) (*App, error) {
|
||||
// Resolve paths relative to executable or current working directory
|
||||
if cfg.TemplatesDir == "" {
|
||||
// Try multiple locations for templates
|
||||
possiblePaths := []string{
|
||||
"web/templates",
|
||||
"./web/templates",
|
||||
"/opt/atlas/web/templates",
|
||||
}
|
||||
for _, path := range possiblePaths {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
cfg.TemplatesDir = path
|
||||
break
|
||||
}
|
||||
}
|
||||
if cfg.TemplatesDir == "" {
|
||||
cfg.TemplatesDir = "web/templates" // Default fallback
|
||||
}
|
||||
}
|
||||
if cfg.StaticDir == "" {
|
||||
// Try multiple locations for static files
|
||||
possiblePaths := []string{
|
||||
"web/static",
|
||||
"./web/static",
|
||||
"/opt/atlas/web/static",
|
||||
}
|
||||
for _, path := range possiblePaths {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
cfg.StaticDir = path
|
||||
break
|
||||
}
|
||||
}
|
||||
if cfg.StaticDir == "" {
|
||||
cfg.StaticDir = "web/static" // Default fallback
|
||||
}
|
||||
}
|
||||
|
||||
tmpl, err := parseTemplates(cfg.TemplatesDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
zfsService := zfs.New()
|
||||
policyStore := snapshot.NewPolicyStore()
|
||||
jobMgr := job.NewManager()
|
||||
scheduler := snapshot.NewScheduler(policyStore, zfsService, jobMgr)
|
||||
|
||||
// Initialize database (optional)
|
||||
var database *db.DB
|
||||
if cfg.DatabasePath != "" {
|
||||
dbConn, err := db.New(cfg.DatabasePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("init database: %w", err)
|
||||
}
|
||||
database = dbConn
|
||||
}
|
||||
|
||||
// Initialize auth
|
||||
jwtSecret := os.Getenv("ATLAS_JWT_SECRET")
|
||||
authService := auth.New(jwtSecret)
|
||||
userStore := auth.NewUserStore(authService)
|
||||
|
||||
// Initialize audit logging (keep last 10000 logs)
|
||||
auditStore := audit.NewStore(10000)
|
||||
|
||||
// Initialize storage services
|
||||
smbStore := storage.NewSMBStore()
|
||||
nfsStore := storage.NewNFSStore()
|
||||
iscsiStore := storage.NewISCSIStore()
|
||||
|
||||
// Initialize service daemon integrations
|
||||
smbService := services.NewSMBService()
|
||||
nfsService := services.NewNFSService()
|
||||
iscsiService := services.NewISCSIService()
|
||||
|
||||
// Initialize metrics collector
|
||||
metricsCollector := metrics.NewCollector()
|
||||
startTime := time.Now()
|
||||
|
||||
// Initialize backup service
|
||||
backupDir := os.Getenv("ATLAS_BACKUP_DIR")
|
||||
if backupDir == "" {
|
||||
backupDir = "data/backups"
|
||||
}
|
||||
backupService, err := backup.New(backupDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("init backup service: %w", err)
|
||||
}
|
||||
|
||||
// Initialize maintenance service
|
||||
maintenanceService := maintenance.NewService()
|
||||
|
||||
// Initialize TLS configuration
|
||||
tlsConfig := tls.LoadConfig()
|
||||
if err := tlsConfig.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("TLS configuration: %w", err)
|
||||
}
|
||||
|
||||
a := &App{
|
||||
cfg: cfg,
|
||||
tmpl: tmpl,
|
||||
mux: http.NewServeMux(),
|
||||
zfs: zfsService,
|
||||
snapshotPolicy: policyStore,
|
||||
jobManager: jobMgr,
|
||||
scheduler: scheduler,
|
||||
authService: authService,
|
||||
userStore: userStore,
|
||||
auditStore: auditStore,
|
||||
smbStore: smbStore,
|
||||
nfsStore: nfsStore,
|
||||
iscsiStore: iscsiStore,
|
||||
database: database,
|
||||
smbService: smbService,
|
||||
nfsService: nfsService,
|
||||
iscsiService: iscsiService,
|
||||
metricsCollector: metricsCollector,
|
||||
startTime: startTime,
|
||||
backupService: backupService,
|
||||
maintenanceService: maintenanceService,
|
||||
tlsConfig: tlsConfig,
|
||||
cache: NewResponseCache(5 * time.Minute),
|
||||
}
|
||||
|
||||
// Start snapshot scheduler (runs every 15 minutes)
|
||||
scheduler.Start(15 * time.Minute)
|
||||
|
||||
a.routes()
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a *App) Router() http.Handler {
|
||||
// Middleware chain order (outer to inner):
|
||||
// 1. HTTPS enforcement (redirect HTTP to HTTPS)
|
||||
// 2. CORS (handles preflight)
|
||||
// 3. Compression (gzip)
|
||||
// 4. Security headers
|
||||
// 5. Request size limit (10MB)
|
||||
// 6. Content-Type validation
|
||||
// 7. Rate limiting
|
||||
// 8. Caching (for GET requests)
|
||||
// 9. Error recovery
|
||||
// 10. Request ID
|
||||
// 11. Logging
|
||||
// 12. Audit
|
||||
// 13. Authentication
|
||||
// 14. Maintenance mode (blocks operations during maintenance)
|
||||
// 15. Routes
|
||||
return a.httpsEnforcementMiddleware(
|
||||
a.corsMiddleware(
|
||||
a.compressionMiddleware(
|
||||
a.securityHeadersMiddleware(
|
||||
a.requestSizeMiddleware(10 * 1024 * 1024)(
|
||||
a.validateContentTypeMiddleware(
|
||||
a.rateLimitMiddleware(
|
||||
a.cacheMiddleware(
|
||||
a.errorMiddleware(
|
||||
requestID(
|
||||
logging(
|
||||
a.auditMiddleware(
|
||||
a.maintenanceMiddleware(
|
||||
a.authMiddleware(a.mux),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// StopScheduler stops the snapshot scheduler (for graceful shutdown)
|
||||
func (a *App) StopScheduler() {
|
||||
if a.scheduler != nil {
|
||||
a.scheduler.Stop()
|
||||
}
|
||||
// Close database connection if present
|
||||
if a.database != nil {
|
||||
a.database.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// routes() is now in routes.go
|
||||
|
||||
func parseTemplates(dir string) (*template.Template, error) {
|
||||
pattern := filepath.Join(dir, "*.html")
|
||||
files, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Allow empty templates for testing
|
||||
if len(files) == 0 {
|
||||
// Return empty template instead of error for testing
|
||||
return template.New("root"), nil
|
||||
}
|
||||
|
||||
funcs := template.FuncMap{
|
||||
"nowRFC3339": func() string { return time.Now().Format(time.RFC3339) },
|
||||
"getContentTemplate": func(data map[string]any) string {
|
||||
if ct, ok := data["ContentTemplate"].(string); ok && ct != "" {
|
||||
return ct
|
||||
}
|
||||
return "content"
|
||||
},
|
||||
}
|
||||
|
||||
t := template.New("root").Funcs(funcs)
|
||||
return t.ParseFiles(files...)
|
||||
}
|
||||
132
internal/httpapp/audit_middleware.go
Normal file
132
internal/httpapp/audit_middleware.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package httpapp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// auditMiddleware logs all mutating operations
|
||||
func (a *App) auditMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Only log mutating operations (POST, PUT, DELETE, PATCH)
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Skip audit for public endpoints
|
||||
if a.isPublicEndpoint(r.URL.Path, r.Method) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Get user from context (if authenticated)
|
||||
actor := "system"
|
||||
user, ok := getUserFromContext(r)
|
||||
if ok {
|
||||
actor = user.ID
|
||||
}
|
||||
|
||||
// Extract action from method and path
|
||||
action := extractAction(r.Method, r.URL.Path)
|
||||
resource := extractResource(r.URL.Path)
|
||||
|
||||
// Get client info
|
||||
ip := getClientIP(r)
|
||||
userAgent := r.UserAgent()
|
||||
|
||||
// Create response writer wrapper to capture status code
|
||||
rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||
|
||||
// Execute the handler
|
||||
next.ServeHTTP(rw, r)
|
||||
|
||||
// Log the operation
|
||||
result := "success"
|
||||
message := ""
|
||||
if rw.statusCode >= 400 {
|
||||
result = "failure"
|
||||
message = http.StatusText(rw.statusCode)
|
||||
}
|
||||
|
||||
a.auditStore.Log(actor, action, resource, result, message, ip, userAgent)
|
||||
})
|
||||
}
|
||||
|
||||
// responseWriter wraps http.ResponseWriter to capture status code
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(code int) {
|
||||
rw.statusCode = code
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// extractAction extracts action name from HTTP method and path
|
||||
func extractAction(method, path string) string {
|
||||
// Remove /api/v1 prefix
|
||||
path = strings.TrimPrefix(path, "/api/v1")
|
||||
path = strings.Trim(path, "/")
|
||||
|
||||
parts := strings.Split(path, "/")
|
||||
resource := parts[0]
|
||||
|
||||
// Map HTTP methods to actions
|
||||
actionMap := map[string]string{
|
||||
http.MethodPost: "create",
|
||||
http.MethodPut: "update",
|
||||
http.MethodPatch: "update",
|
||||
http.MethodDelete: "delete",
|
||||
}
|
||||
|
||||
action := actionMap[method]
|
||||
if action == "" {
|
||||
action = strings.ToLower(method)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s.%s", resource, action)
|
||||
}
|
||||
|
||||
// extractResource extracts resource identifier from path
|
||||
func extractResource(path string) string {
|
||||
// Remove /api/v1 prefix
|
||||
path = strings.TrimPrefix(path, "/api/v1")
|
||||
path = strings.Trim(path, "/")
|
||||
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) == 0 {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
resource := parts[0]
|
||||
if len(parts) > 1 {
|
||||
// Include resource ID if present
|
||||
resource = fmt.Sprintf("%s/%s", resource, parts[1])
|
||||
}
|
||||
|
||||
return resource
|
||||
}
|
||||
|
||||
// getClientIP extracts client IP from request
|
||||
func getClientIP(r *http.Request) string {
|
||||
// Check X-Forwarded-For header (for proxies)
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
ips := strings.Split(xff, ",")
|
||||
return strings.TrimSpace(ips[0])
|
||||
}
|
||||
|
||||
// Check X-Real-IP header
|
||||
if xri := r.Header.Get("X-Real-IP"); xri != "" {
|
||||
return xri
|
||||
}
|
||||
|
||||
// Fallback to RemoteAddr
|
||||
ip := r.RemoteAddr
|
||||
if idx := strings.LastIndex(ip, ":"); idx != -1 {
|
||||
ip = ip[:idx]
|
||||
}
|
||||
return ip
|
||||
}
|
||||
174
internal/httpapp/auth_middleware.go
Normal file
174
internal/httpapp/auth_middleware.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package httpapp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/auth"
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
userCtxKey ctxKey = "user"
|
||||
roleCtxKey ctxKey = "role"
|
||||
)
|
||||
|
||||
// authMiddleware validates JWT tokens and extracts user info
|
||||
func (a *App) authMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Skip auth for public endpoints (includes web UI pages and read-only GET endpoints)
|
||||
if a.isPublicEndpoint(r.URL.Path, r.Method) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract token from Authorization header
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing authorization header"})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse "Bearer <token>"
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid authorization header format"})
|
||||
return
|
||||
}
|
||||
|
||||
token := parts[1]
|
||||
claims, err := a.authService.ValidateToken(token)
|
||||
if err != nil {
|
||||
if err == auth.ErrExpiredToken {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "token expired"})
|
||||
} else {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid token"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Get user from store
|
||||
user, err := a.userStore.GetByID(claims.UserID)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if !user.Active {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "user account is disabled"})
|
||||
return
|
||||
}
|
||||
|
||||
// Add user info to context
|
||||
ctx := context.WithValue(r.Context(), userCtxKey, user)
|
||||
ctx = context.WithValue(ctx, roleCtxKey, user.Role)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// requireRole middleware checks if user has required role
|
||||
func (a *App) requireRole(allowedRoles ...models.Role) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
role, ok := r.Context().Value(roleCtxKey).(models.Role)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user role is in allowed roles
|
||||
allowed := false
|
||||
for _, allowedRole := range allowedRoles {
|
||||
if role == allowedRole {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "insufficient permissions"})
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// isPublicEndpoint checks if an endpoint is public (no auth required)
|
||||
// It validates both path and HTTP method to prevent unauthenticated mutations
|
||||
func (a *App) isPublicEndpoint(path, method string) bool {
|
||||
// Always public paths (any method)
|
||||
publicPaths := []string{
|
||||
"/healthz",
|
||||
"/health",
|
||||
"/metrics",
|
||||
"/api/v1/auth/login",
|
||||
"/api/v1/auth/logout",
|
||||
"/", // Dashboard
|
||||
"/login", // Login page
|
||||
"/storage", // Storage management page
|
||||
"/shares", // Shares page
|
||||
"/iscsi", // iSCSI page
|
||||
"/protection", // Data Protection page
|
||||
"/management", // System Management page
|
||||
"/api/docs", // API documentation
|
||||
"/api/openapi.yaml", // OpenAPI spec
|
||||
}
|
||||
|
||||
for _, publicPath := range publicPaths {
|
||||
if path == publicPath {
|
||||
return true
|
||||
}
|
||||
// Also allow paths that start with public paths (for sub-pages)
|
||||
if strings.HasPrefix(path, publicPath+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Static files are public (any method)
|
||||
if strings.HasPrefix(path, "/static/") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Read-only GET endpoints are public for web UI (but require auth for mutations)
|
||||
// SECURITY: Only GET requests are allowed without authentication
|
||||
// POST, PUT, DELETE, PATCH require authentication
|
||||
publicReadOnlyPaths := []string{
|
||||
"/api/v1/dashboard", // Dashboard data
|
||||
"/api/v1/disks", // List disks
|
||||
"/api/v1/pools", // List pools (GET only)
|
||||
"/api/v1/pools/available", // List available pools
|
||||
"/api/v1/datasets", // List datasets (GET only)
|
||||
"/api/v1/zvols", // List ZVOLs (GET only)
|
||||
"/api/v1/shares/smb", // List SMB shares (GET only)
|
||||
"/api/v1/exports/nfs", // List NFS exports (GET only)
|
||||
"/api/v1/iscsi/targets", // List iSCSI targets (GET only)
|
||||
"/api/v1/snapshots", // List snapshots (GET only)
|
||||
"/api/v1/snapshot-policies", // List snapshot policies (GET only)
|
||||
}
|
||||
|
||||
for _, publicPath := range publicReadOnlyPaths {
|
||||
if path == publicPath {
|
||||
// Only allow GET requests without authentication
|
||||
// All mutation methods (POST, PUT, DELETE, PATCH) require authentication
|
||||
return method == http.MethodGet
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// getUserFromContext extracts user from request context
|
||||
func getUserFromContext(r *http.Request) (*models.User, bool) {
|
||||
user, ok := r.Context().Value(userCtxKey).(*models.User)
|
||||
return user, ok
|
||||
}
|
||||
|
||||
// getRoleFromContext extracts role from request context
|
||||
func getRoleFromContext(r *http.Request) (models.Role, bool) {
|
||||
role, ok := r.Context().Value(roleCtxKey).(models.Role)
|
||||
return role, ok
|
||||
}
|
||||
304
internal/httpapp/backup_handlers.go
Normal file
304
internal/httpapp/backup_handlers.go
Normal file
@@ -0,0 +1,304 @@
|
||||
package httpapp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"strings"
|
||||
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/backup"
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/errors"
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/models"
|
||||
)
|
||||
|
||||
// Backup Handlers
|
||||
func (a *App) handleCreateBackup(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
// Description is optional, so we'll continue even if body is empty
|
||||
_ = err
|
||||
}
|
||||
|
||||
// Collect all configuration data
|
||||
backupData := backup.BackupData{
|
||||
Users: a.userStore.List(),
|
||||
SMBShares: a.smbStore.List(),
|
||||
NFSExports: a.nfsStore.List(),
|
||||
ISCSITargets: a.iscsiStore.List(),
|
||||
Policies: a.snapshotPolicy.List(),
|
||||
Config: map[string]interface{}{
|
||||
"database_path": a.cfg.DatabasePath,
|
||||
},
|
||||
}
|
||||
|
||||
// Create backup
|
||||
backupID, err := a.backupService.CreateBackup(backupData, req.Description)
|
||||
if err != nil {
|
||||
log.Printf("create backup error: %v", err)
|
||||
writeError(w, errors.ErrInternal("failed to create backup").WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Get backup metadata
|
||||
metadata, err := a.backupService.GetBackup(backupID)
|
||||
if err != nil {
|
||||
log.Printf("get backup metadata error: %v", err)
|
||||
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||
"id": backupID,
|
||||
"message": "backup created",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, metadata)
|
||||
}
|
||||
|
||||
func (a *App) handleListBackups(w http.ResponseWriter, r *http.Request) {
|
||||
backups, err := a.backupService.ListBackups()
|
||||
if err != nil {
|
||||
log.Printf("list backups error: %v", err)
|
||||
writeError(w, errors.ErrInternal("failed to list backups").WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, backups)
|
||||
}
|
||||
|
||||
func (a *App) handleGetBackup(w http.ResponseWriter, r *http.Request) {
|
||||
backupID := pathParam(r, "/api/v1/backups/")
|
||||
if backupID == "" {
|
||||
writeError(w, errors.ErrBadRequest("backup id required"))
|
||||
return
|
||||
}
|
||||
|
||||
metadata, err := a.backupService.GetBackup(backupID)
|
||||
if err != nil {
|
||||
log.Printf("get backup error: %v", err)
|
||||
writeError(w, errors.ErrNotFound("backup").WithDetails(backupID))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, metadata)
|
||||
}
|
||||
|
||||
func (a *App) handleRestoreBackup(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract backup ID from path
|
||||
path := r.URL.Path
|
||||
backupID := ""
|
||||
|
||||
// Handle both /api/v1/backups/{id} and /api/v1/backups/{id}/restore
|
||||
if strings.Contains(path, "/restore") {
|
||||
// Path: /api/v1/backups/{id}/restore
|
||||
prefix := "/api/v1/backups/"
|
||||
suffix := "/restore"
|
||||
if strings.HasPrefix(path, prefix) && strings.HasSuffix(path, suffix) {
|
||||
backupID = path[len(prefix) : len(path)-len(suffix)]
|
||||
}
|
||||
} else {
|
||||
// Path: /api/v1/backups/{id}
|
||||
backupID = pathParam(r, "/api/v1/backups/")
|
||||
}
|
||||
|
||||
if backupID == "" {
|
||||
writeError(w, errors.ErrBadRequest("backup id required"))
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
// Dry run is optional, default to false
|
||||
req.DryRun = false
|
||||
}
|
||||
|
||||
// Verify backup first
|
||||
if err := a.backupService.VerifyBackup(backupID); err != nil {
|
||||
log.Printf("verify backup error: %v", err)
|
||||
writeError(w, errors.ErrBadRequest("backup verification failed").WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Restore backup
|
||||
backupData, err := a.backupService.RestoreBackup(backupID)
|
||||
if err != nil {
|
||||
log.Printf("restore backup error: %v", err)
|
||||
writeError(w, errors.ErrInternal("failed to restore backup").WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if req.DryRun {
|
||||
// Return what would be restored without actually restoring
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "dry run - no changes made",
|
||||
"backup_id": backupID,
|
||||
"backup_data": backupData,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Restore users (skip default admin user - user-1)
|
||||
// Note: Passwords cannot be restored as they're hashed and not stored in user model
|
||||
// Users will need to reset their passwords after restore
|
||||
for _, user := range backupData.Users {
|
||||
// Skip default admin user
|
||||
if user.ID == "user-1" {
|
||||
log.Printf("skipping default admin user")
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
if _, err := a.userStore.GetByID(user.ID); err == nil {
|
||||
log.Printf("user %s already exists, skipping", user.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create user with temporary password (user must reset password)
|
||||
// Use a secure random password that user must change
|
||||
tempPassword := fmt.Sprintf("restore-%s", user.ID)
|
||||
if _, err := a.userStore.Create(user.Username, user.Email, tempPassword, user.Role); err != nil {
|
||||
log.Printf("restore user error: %v", err)
|
||||
// Continue with other users
|
||||
} else {
|
||||
log.Printf("restored user %s - password reset required", user.Username)
|
||||
}
|
||||
}
|
||||
|
||||
// Restore SMB shares
|
||||
for _, share := range backupData.SMBShares {
|
||||
// Check if share already exists
|
||||
if _, err := a.smbStore.Get(share.ID); err == nil {
|
||||
log.Printf("SMB share %s already exists, skipping", share.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create share
|
||||
if _, err := a.smbStore.Create(share.Name, share.Path, share.Dataset, share.Description, share.ReadOnly, share.GuestOK, share.ValidUsers); err != nil {
|
||||
log.Printf("restore SMB share error: %v", err)
|
||||
// Continue with other shares
|
||||
}
|
||||
}
|
||||
|
||||
// Restore NFS exports
|
||||
for _, export := range backupData.NFSExports {
|
||||
// Check if export already exists
|
||||
if _, err := a.nfsStore.Get(export.ID); err == nil {
|
||||
log.Printf("NFS export %s already exists, skipping", export.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create export
|
||||
if _, err := a.nfsStore.Create(export.Path, export.Dataset, export.Clients, export.ReadOnly, export.RootSquash); err != nil {
|
||||
log.Printf("restore NFS export error: %v", err)
|
||||
// Continue with other exports
|
||||
}
|
||||
}
|
||||
|
||||
// Restore iSCSI targets
|
||||
for _, target := range backupData.ISCSITargets {
|
||||
// Check if target already exists
|
||||
if _, err := a.iscsiStore.Get(target.ID); err == nil {
|
||||
log.Printf("iSCSI target %s already exists, skipping", target.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create target
|
||||
if _, err := a.iscsiStore.Create(target.IQN, target.Initiators); err != nil {
|
||||
log.Printf("restore iSCSI target error: %v", err)
|
||||
// Continue with other targets
|
||||
}
|
||||
|
||||
// Restore LUNs
|
||||
for _, lun := range target.LUNs {
|
||||
if _, err := a.iscsiStore.AddLUN(target.ID, lun.ZVOL, lun.Size); err != nil {
|
||||
log.Printf("restore iSCSI LUN error: %v", err)
|
||||
// Continue with other LUNs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore snapshot policies
|
||||
for _, policy := range backupData.Policies {
|
||||
// Check if policy already exists
|
||||
if existing, _ := a.snapshotPolicy.Get(policy.Dataset); existing != nil {
|
||||
log.Printf("snapshot policy for dataset %s already exists, skipping", policy.Dataset)
|
||||
continue
|
||||
}
|
||||
|
||||
// Set policy (uses Dataset as key)
|
||||
a.snapshotPolicy.Set(&policy)
|
||||
}
|
||||
|
||||
// Apply service configurations
|
||||
shares := a.smbStore.List()
|
||||
if err := a.smbService.ApplyConfiguration(shares); err != nil {
|
||||
log.Printf("apply SMB configuration after restore error: %v", err)
|
||||
}
|
||||
|
||||
exports := a.nfsStore.List()
|
||||
if err := a.nfsService.ApplyConfiguration(exports); err != nil {
|
||||
log.Printf("apply NFS configuration after restore error: %v", err)
|
||||
}
|
||||
|
||||
targets := a.iscsiStore.List()
|
||||
for _, target := range targets {
|
||||
if err := a.iscsiService.ApplyConfiguration([]models.ISCSITarget{target}); err != nil {
|
||||
log.Printf("apply iSCSI configuration after restore error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "backup restored successfully",
|
||||
"backup_id": backupID,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) handleDeleteBackup(w http.ResponseWriter, r *http.Request) {
|
||||
backupID := pathParam(r, "/api/v1/backups/")
|
||||
if backupID == "" {
|
||||
writeError(w, errors.ErrBadRequest("backup id required"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := a.backupService.DeleteBackup(backupID); err != nil {
|
||||
log.Printf("delete backup error: %v", err)
|
||||
writeError(w, errors.ErrInternal("failed to delete backup").WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"message": "backup deleted",
|
||||
"backup_id": backupID,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) handleVerifyBackup(w http.ResponseWriter, r *http.Request) {
|
||||
backupID := pathParam(r, "/api/v1/backups/")
|
||||
if backupID == "" {
|
||||
writeError(w, errors.ErrBadRequest("backup id required"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := a.backupService.VerifyBackup(backupID); err != nil {
|
||||
writeError(w, errors.ErrBadRequest("backup verification failed").WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
metadata, err := a.backupService.GetBackup(backupID)
|
||||
if err != nil {
|
||||
writeError(w, errors.ErrNotFound("backup").WithDetails(backupID))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "backup is valid",
|
||||
"backup_id": backupID,
|
||||
"metadata": metadata,
|
||||
})
|
||||
}
|
||||
247
internal/httpapp/cache_middleware.go
Normal file
247
internal/httpapp/cache_middleware.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package httpapp
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CacheEntry represents a cached response
|
||||
type CacheEntry struct {
|
||||
Body []byte
|
||||
Headers map[string]string
|
||||
StatusCode int
|
||||
ExpiresAt time.Time
|
||||
ETag string
|
||||
}
|
||||
|
||||
// ResponseCache provides HTTP response caching
|
||||
type ResponseCache struct {
|
||||
mu sync.RWMutex
|
||||
cache map[string]*CacheEntry
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
// NewResponseCache creates a new response cache
|
||||
func NewResponseCache(ttl time.Duration) *ResponseCache {
|
||||
c := &ResponseCache{
|
||||
cache: make(map[string]*CacheEntry),
|
||||
ttl: ttl,
|
||||
}
|
||||
|
||||
// Start cleanup goroutine
|
||||
go c.cleanup()
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// cleanup periodically removes expired entries
|
||||
func (c *ResponseCache) cleanup() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
c.mu.Lock()
|
||||
now := time.Now()
|
||||
for key, entry := range c.cache {
|
||||
if now.After(entry.ExpiresAt) {
|
||||
delete(c.cache, key)
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Get retrieves a cached entry
|
||||
func (c *ResponseCache) Get(key string) (*CacheEntry, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
entry, exists := c.cache[key]
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if time.Now().After(entry.ExpiresAt) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return entry, true
|
||||
}
|
||||
|
||||
// Set stores a cached entry
|
||||
func (c *ResponseCache) Set(key string, entry *CacheEntry) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
entry.ExpiresAt = time.Now().Add(c.ttl)
|
||||
c.cache[key] = entry
|
||||
}
|
||||
|
||||
// Invalidate removes a cached entry
|
||||
func (c *ResponseCache) Invalidate(key string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.cache, key)
|
||||
}
|
||||
|
||||
// InvalidatePattern removes entries matching a pattern
|
||||
func (c *ResponseCache) InvalidatePattern(pattern string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
for key := range c.cache {
|
||||
if containsPattern(key, pattern) {
|
||||
delete(c.cache, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// containsPattern checks if a string contains a pattern (simple prefix/suffix matching)
|
||||
func containsPattern(s, pattern string) bool {
|
||||
// Simple pattern matching - can be enhanced
|
||||
return len(s) >= len(pattern) && (s[:len(pattern)] == pattern || s[len(s)-len(pattern):] == pattern)
|
||||
}
|
||||
|
||||
// generateCacheKey creates a cache key from request
|
||||
func generateCacheKey(r *http.Request) string {
|
||||
// Include method, path, and query string
|
||||
key := r.Method + ":" + r.URL.Path
|
||||
if r.URL.RawQuery != "" {
|
||||
key += "?" + r.URL.RawQuery
|
||||
}
|
||||
|
||||
// Hash the key for consistent length
|
||||
hash := sha256.Sum256([]byte(key))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// generateETag generates an ETag from content
|
||||
func generateETag(content []byte) string {
|
||||
hash := sha256.Sum256(content)
|
||||
return `"` + hex.EncodeToString(hash[:16]) + `"`
|
||||
}
|
||||
|
||||
// cacheMiddleware provides response caching for GET requests
|
||||
func (a *App) cacheMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Only cache GET requests
|
||||
if r.Method != http.MethodGet {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Skip caching for authenticated endpoints that may have user-specific data
|
||||
if !a.isPublicEndpoint(r.URL.Path, r.Method) {
|
||||
// Check if user is authenticated - if so, skip caching
|
||||
// In production, you might want per-user caching by including user ID in cache key
|
||||
if _, ok := getUserFromContext(r); ok {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Skip caching for certain endpoints
|
||||
if a.shouldSkipCache(r.URL.Path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Check cache
|
||||
cacheKey := generateCacheKey(r)
|
||||
if entry, found := a.cache.Get(cacheKey); found {
|
||||
// Check If-None-Match header for ETag validation
|
||||
ifNoneMatch := r.Header.Get("If-None-Match")
|
||||
if ifNoneMatch == entry.ETag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
|
||||
// Serve from cache
|
||||
for k, v := range entry.Headers {
|
||||
w.Header().Set(k, v)
|
||||
}
|
||||
w.Header().Set("ETag", entry.ETag)
|
||||
w.Header().Set("X-Cache", "HIT")
|
||||
w.WriteHeader(entry.StatusCode)
|
||||
w.Write(entry.Body)
|
||||
return
|
||||
}
|
||||
|
||||
// Create response writer to capture response
|
||||
rw := &cacheResponseWriter{
|
||||
ResponseWriter: w,
|
||||
statusCode: http.StatusOK,
|
||||
body: make([]byte, 0),
|
||||
}
|
||||
|
||||
next.ServeHTTP(rw, r)
|
||||
|
||||
// Only cache successful responses
|
||||
if rw.statusCode >= 200 && rw.statusCode < 300 {
|
||||
// Generate ETag
|
||||
etag := generateETag(rw.body)
|
||||
|
||||
// Store in cache
|
||||
headers := make(map[string]string)
|
||||
for k, v := range rw.Header() {
|
||||
if len(v) > 0 {
|
||||
headers[k] = v[0]
|
||||
}
|
||||
}
|
||||
|
||||
entry := &CacheEntry{
|
||||
Body: rw.body,
|
||||
Headers: headers,
|
||||
StatusCode: rw.statusCode,
|
||||
ETag: etag,
|
||||
}
|
||||
|
||||
a.cache.Set(cacheKey, entry)
|
||||
|
||||
// Add cache headers
|
||||
w.Header().Set("ETag", etag)
|
||||
w.Header().Set("X-Cache", "MISS")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// cacheResponseWriter captures response for caching
|
||||
type cacheResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
body []byte
|
||||
}
|
||||
|
||||
func (rw *cacheResponseWriter) WriteHeader(code int) {
|
||||
rw.statusCode = code
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (rw *cacheResponseWriter) Write(b []byte) (int, error) {
|
||||
rw.body = append(rw.body, b...)
|
||||
return rw.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// shouldSkipCache determines if a path should skip caching
|
||||
func (a *App) shouldSkipCache(path string) bool {
|
||||
// Skip caching for dynamic endpoints
|
||||
skipPaths := []string{
|
||||
"/metrics",
|
||||
"/healthz",
|
||||
"/health",
|
||||
"/api/v1/system/info",
|
||||
"/api/v1/system/logs",
|
||||
"/api/v1/dashboard",
|
||||
}
|
||||
|
||||
for _, skipPath := range skipPaths {
|
||||
if path == skipPath {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
54
internal/httpapp/compression_middleware.go
Normal file
54
internal/httpapp/compression_middleware.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package httpapp
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// compressionMiddleware provides gzip compression for responses
|
||||
func (a *App) compressionMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Check if client accepts gzip
|
||||
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Skip compression for certain content types
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
if strings.HasPrefix(contentType, "image/") ||
|
||||
strings.HasPrefix(contentType, "video/") ||
|
||||
strings.HasPrefix(contentType, "application/zip") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Create gzip writer
|
||||
gz := gzip.NewWriter(w)
|
||||
defer gz.Close()
|
||||
|
||||
// Set headers
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
w.Header().Set("Vary", "Accept-Encoding")
|
||||
|
||||
// Wrap response writer
|
||||
gzw := &gzipResponseWriter{
|
||||
ResponseWriter: w,
|
||||
Writer: gz,
|
||||
}
|
||||
|
||||
next.ServeHTTP(gzw, r)
|
||||
})
|
||||
}
|
||||
|
||||
// gzipResponseWriter wraps http.ResponseWriter with gzip compression
|
||||
type gzipResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
Writer io.Writer
|
||||
}
|
||||
|
||||
func (gzw *gzipResponseWriter) Write(b []byte) (int, error) {
|
||||
return gzw.Writer.Write(b)
|
||||
}
|
||||
130
internal/httpapp/dashboard_handlers.go
Normal file
130
internal/httpapp/dashboard_handlers.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package httpapp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// DashboardData represents aggregated dashboard statistics
|
||||
type DashboardData struct {
|
||||
Storage struct {
|
||||
TotalCapacity uint64 `json:"total_capacity"`
|
||||
TotalAllocated uint64 `json:"total_allocated"`
|
||||
TotalAvailable uint64 `json:"total_available"`
|
||||
PoolCount int `json:"pool_count"`
|
||||
DatasetCount int `json:"dataset_count"`
|
||||
ZVOLCount int `json:"zvol_count"`
|
||||
SnapshotCount int `json:"snapshot_count"`
|
||||
} `json:"storage"`
|
||||
Services struct {
|
||||
SMBShares int `json:"smb_shares"`
|
||||
NFSExports int `json:"nfs_exports"`
|
||||
ISCSITargets int `json:"iscsi_targets"`
|
||||
SMBStatus bool `json:"smb_status"`
|
||||
NFSStatus bool `json:"nfs_status"`
|
||||
ISCSIStatus bool `json:"iscsi_status"`
|
||||
} `json:"services"`
|
||||
Jobs struct {
|
||||
Total int `json:"total"`
|
||||
Running int `json:"running"`
|
||||
Completed int `json:"completed"`
|
||||
Failed int `json:"failed"`
|
||||
} `json:"jobs"`
|
||||
RecentAuditLogs []map[string]interface{} `json:"recent_audit_logs,omitempty"`
|
||||
}
|
||||
|
||||
// handleDashboardAPI returns aggregated dashboard data
|
||||
func (a *App) handleDashboardAPI(w http.ResponseWriter, r *http.Request) {
|
||||
data := DashboardData{}
|
||||
|
||||
// Storage statistics
|
||||
pools, err := a.zfs.ListPools()
|
||||
if err == nil {
|
||||
data.Storage.PoolCount = len(pools)
|
||||
for _, pool := range pools {
|
||||
data.Storage.TotalCapacity += pool.Size
|
||||
data.Storage.TotalAllocated += pool.Allocated
|
||||
data.Storage.TotalAvailable += pool.Free
|
||||
}
|
||||
}
|
||||
|
||||
datasets, err := a.zfs.ListDatasets("")
|
||||
if err == nil {
|
||||
data.Storage.DatasetCount = len(datasets)
|
||||
}
|
||||
|
||||
zvols, err := a.zfs.ListZVOLs("")
|
||||
if err == nil {
|
||||
data.Storage.ZVOLCount = len(zvols)
|
||||
}
|
||||
|
||||
snapshots, err := a.zfs.ListSnapshots("")
|
||||
if err == nil {
|
||||
data.Storage.SnapshotCount = len(snapshots)
|
||||
}
|
||||
|
||||
// Service statistics
|
||||
smbShares := a.smbStore.List()
|
||||
data.Services.SMBShares = len(smbShares)
|
||||
|
||||
nfsExports := a.nfsStore.List()
|
||||
data.Services.NFSExports = len(nfsExports)
|
||||
|
||||
iscsiTargets := a.iscsiStore.List()
|
||||
data.Services.ISCSITargets = len(iscsiTargets)
|
||||
|
||||
// Service status
|
||||
if a.smbService != nil {
|
||||
data.Services.SMBStatus, _ = a.smbService.GetStatus()
|
||||
}
|
||||
if a.nfsService != nil {
|
||||
data.Services.NFSStatus, _ = a.nfsService.GetStatus()
|
||||
}
|
||||
if a.iscsiService != nil {
|
||||
data.Services.ISCSIStatus, _ = a.iscsiService.GetStatus()
|
||||
}
|
||||
|
||||
// Job statistics
|
||||
allJobs := a.jobManager.List("")
|
||||
data.Jobs.Total = len(allJobs)
|
||||
for _, job := range allJobs {
|
||||
switch job.Status {
|
||||
case "running":
|
||||
data.Jobs.Running++
|
||||
case "completed":
|
||||
data.Jobs.Completed++
|
||||
case "failed":
|
||||
data.Jobs.Failed++
|
||||
}
|
||||
}
|
||||
|
||||
// Recent audit logs (last 5)
|
||||
auditLogs := a.auditStore.List("", "", "", 5)
|
||||
data.RecentAuditLogs = make([]map[string]interface{}, 0, len(auditLogs))
|
||||
for _, log := range auditLogs {
|
||||
data.RecentAuditLogs = append(data.RecentAuditLogs, map[string]interface{}{
|
||||
"id": log.ID,
|
||||
"actor": log.Actor,
|
||||
"action": log.Action,
|
||||
"resource": log.Resource,
|
||||
"result": log.Result,
|
||||
"timestamp": log.Timestamp.Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, data)
|
||||
}
|
||||
|
||||
// formatBytes formats bytes to human-readable format
|
||||
func formatBytes(bytes uint64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
299
internal/httpapp/diagnostics_handlers.go
Normal file
299
internal/httpapp/diagnostics_handlers.go
Normal file
@@ -0,0 +1,299 @@
|
||||
package httpapp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SystemInfo represents system diagnostic information
|
||||
type SystemInfo struct {
|
||||
Version string `json:"version"`
|
||||
Uptime string `json:"uptime"`
|
||||
GoVersion string `json:"go_version"`
|
||||
NumGoroutine int `json:"num_goroutines"`
|
||||
Memory MemoryInfo `json:"memory"`
|
||||
Services map[string]ServiceInfo `json:"services"`
|
||||
Database DatabaseInfo `json:"database,omitempty"`
|
||||
}
|
||||
|
||||
// MemoryInfo represents memory statistics
|
||||
type MemoryInfo struct {
|
||||
Alloc uint64 `json:"alloc"` // bytes allocated
|
||||
TotalAlloc uint64 `json:"total_alloc"` // bytes allocated (cumulative)
|
||||
Sys uint64 `json:"sys"` // bytes obtained from system
|
||||
NumGC uint32 `json:"num_gc"` // number of GC cycles
|
||||
}
|
||||
|
||||
// ServiceInfo represents service status
|
||||
type ServiceInfo struct {
|
||||
Status string `json:"status"` // "running", "stopped", "error"
|
||||
LastCheck string `json:"last_check"` // timestamp
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// DatabaseInfo represents database connection info
|
||||
type DatabaseInfo struct {
|
||||
Connected bool `json:"connected"`
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
// handleSystemInfo returns system diagnostic information
|
||||
func (a *App) handleSystemInfo(w http.ResponseWriter, r *http.Request) {
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
|
||||
uptime := time.Since(a.startTime)
|
||||
|
||||
info := SystemInfo{
|
||||
Version: "v0.1.0-dev",
|
||||
Uptime: fmt.Sprintf("%.0f seconds", uptime.Seconds()),
|
||||
GoVersion: runtime.Version(),
|
||||
NumGoroutine: runtime.NumGoroutine(),
|
||||
Memory: MemoryInfo{
|
||||
Alloc: m.Alloc,
|
||||
TotalAlloc: m.TotalAlloc,
|
||||
Sys: m.Sys,
|
||||
NumGC: m.NumGC,
|
||||
},
|
||||
Services: make(map[string]ServiceInfo),
|
||||
}
|
||||
|
||||
// Check service statuses
|
||||
smbStatus, smbErr := a.smbService.GetStatus()
|
||||
if smbErr == nil {
|
||||
status := "stopped"
|
||||
if smbStatus {
|
||||
status = "running"
|
||||
}
|
||||
info.Services["smb"] = ServiceInfo{
|
||||
Status: status,
|
||||
LastCheck: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
} else {
|
||||
info.Services["smb"] = ServiceInfo{
|
||||
Status: "error",
|
||||
LastCheck: time.Now().Format(time.RFC3339),
|
||||
Message: smbErr.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
nfsStatus, nfsErr := a.nfsService.GetStatus()
|
||||
if nfsErr == nil {
|
||||
status := "stopped"
|
||||
if nfsStatus {
|
||||
status = "running"
|
||||
}
|
||||
info.Services["nfs"] = ServiceInfo{
|
||||
Status: status,
|
||||
LastCheck: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
} else {
|
||||
info.Services["nfs"] = ServiceInfo{
|
||||
Status: "error",
|
||||
LastCheck: time.Now().Format(time.RFC3339),
|
||||
Message: nfsErr.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
iscsiStatus, iscsiErr := a.iscsiService.GetStatus()
|
||||
if iscsiErr == nil {
|
||||
status := "stopped"
|
||||
if iscsiStatus {
|
||||
status = "running"
|
||||
}
|
||||
info.Services["iscsi"] = ServiceInfo{
|
||||
Status: status,
|
||||
LastCheck: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
} else {
|
||||
info.Services["iscsi"] = ServiceInfo{
|
||||
Status: "error",
|
||||
LastCheck: time.Now().Format(time.RFC3339),
|
||||
Message: iscsiErr.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
// Database info
|
||||
if a.database != nil {
|
||||
info.Database = DatabaseInfo{
|
||||
Connected: true,
|
||||
Path: a.cfg.DatabasePath,
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, info)
|
||||
}
|
||||
|
||||
// handleHealthCheck provides detailed health check information
|
||||
func (a *App) handleHealthCheck(w http.ResponseWriter, r *http.Request) {
|
||||
type HealthStatus struct {
|
||||
Status string `json:"status"` // "healthy", "degraded", "unhealthy"
|
||||
Timestamp string `json:"timestamp"`
|
||||
Checks map[string]string `json:"checks"`
|
||||
}
|
||||
|
||||
health := HealthStatus{
|
||||
Status: "healthy",
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
Checks: make(map[string]string),
|
||||
}
|
||||
|
||||
// Check ZFS service
|
||||
if a.zfs != nil {
|
||||
_, err := a.zfs.ListPools()
|
||||
if err != nil {
|
||||
health.Checks["zfs"] = "unhealthy: " + err.Error()
|
||||
health.Status = "degraded"
|
||||
} else {
|
||||
health.Checks["zfs"] = "healthy"
|
||||
}
|
||||
} else {
|
||||
health.Checks["zfs"] = "unhealthy: service not initialized"
|
||||
health.Status = "unhealthy"
|
||||
}
|
||||
|
||||
// Check database
|
||||
if a.database != nil {
|
||||
// Try a simple query to check database health
|
||||
if err := a.database.DB.Ping(); err != nil {
|
||||
health.Checks["database"] = "unhealthy: " + err.Error()
|
||||
health.Status = "degraded"
|
||||
} else {
|
||||
health.Checks["database"] = "healthy"
|
||||
}
|
||||
} else {
|
||||
health.Checks["database"] = "not configured"
|
||||
}
|
||||
|
||||
// Check services
|
||||
smbStatus, smbErr := a.smbService.GetStatus()
|
||||
if smbErr != nil {
|
||||
health.Checks["smb"] = "unhealthy: " + smbErr.Error()
|
||||
health.Status = "degraded"
|
||||
} else if !smbStatus {
|
||||
health.Checks["smb"] = "stopped"
|
||||
} else {
|
||||
health.Checks["smb"] = "healthy"
|
||||
}
|
||||
|
||||
nfsStatus, nfsErr := a.nfsService.GetStatus()
|
||||
if nfsErr != nil {
|
||||
health.Checks["nfs"] = "unhealthy: " + nfsErr.Error()
|
||||
health.Status = "degraded"
|
||||
} else if !nfsStatus {
|
||||
health.Checks["nfs"] = "stopped"
|
||||
} else {
|
||||
health.Checks["nfs"] = "healthy"
|
||||
}
|
||||
|
||||
iscsiStatus, iscsiErr := a.iscsiService.GetStatus()
|
||||
if iscsiErr != nil {
|
||||
health.Checks["iscsi"] = "unhealthy: " + iscsiErr.Error()
|
||||
health.Status = "degraded"
|
||||
} else if !iscsiStatus {
|
||||
health.Checks["iscsi"] = "stopped"
|
||||
} else {
|
||||
health.Checks["iscsi"] = "healthy"
|
||||
}
|
||||
|
||||
// Check maintenance mode
|
||||
if a.maintenanceService != nil && a.maintenanceService.IsEnabled() {
|
||||
health.Checks["maintenance"] = "enabled"
|
||||
if health.Status == "healthy" {
|
||||
health.Status = "maintenance"
|
||||
}
|
||||
} else {
|
||||
health.Checks["maintenance"] = "disabled"
|
||||
}
|
||||
|
||||
// Set HTTP status based on health
|
||||
statusCode := http.StatusOK
|
||||
if health.Status == "unhealthy" {
|
||||
statusCode = http.StatusServiceUnavailable
|
||||
} else if health.Status == "degraded" {
|
||||
statusCode = http.StatusOK // Still OK, but with warnings
|
||||
}
|
||||
|
||||
w.WriteHeader(statusCode)
|
||||
writeJSON(w, statusCode, health)
|
||||
}
|
||||
|
||||
// handleLogs returns recent log entries (if available)
|
||||
func (a *App) handleLogs(w http.ResponseWriter, r *http.Request) {
|
||||
// For now, return audit logs as system logs
|
||||
// In a full implementation, this would return application logs
|
||||
limit := 100
|
||||
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
|
||||
fmt.Sscanf(limitStr, "%d", &limit)
|
||||
if limit > 1000 {
|
||||
limit = 1000
|
||||
}
|
||||
if limit < 1 {
|
||||
limit = 1
|
||||
}
|
||||
}
|
||||
|
||||
// Get recent audit logs
|
||||
logs := a.auditStore.List("", "", "", limit)
|
||||
|
||||
type LogEntry struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
Level string `json:"level"`
|
||||
Actor string `json:"actor"`
|
||||
Action string `json:"action"`
|
||||
Resource string `json:"resource"`
|
||||
Result string `json:"result"`
|
||||
Message string `json:"message,omitempty"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
}
|
||||
|
||||
entries := make([]LogEntry, 0, len(logs))
|
||||
for _, log := range logs {
|
||||
level := "INFO"
|
||||
if log.Result == "failure" {
|
||||
level = "ERROR"
|
||||
}
|
||||
|
||||
entries = append(entries, LogEntry{
|
||||
Timestamp: log.Timestamp.Format(time.RFC3339),
|
||||
Level: level,
|
||||
Actor: log.Actor,
|
||||
Action: log.Action,
|
||||
Resource: log.Resource,
|
||||
Result: log.Result,
|
||||
Message: log.Message,
|
||||
IP: log.IP,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"logs": entries,
|
||||
"count": len(entries),
|
||||
})
|
||||
}
|
||||
|
||||
// handleGC triggers a garbage collection and returns stats
|
||||
func (a *App) handleGC(w http.ResponseWriter, r *http.Request) {
|
||||
var before, after runtime.MemStats
|
||||
runtime.ReadMemStats(&before)
|
||||
runtime.GC()
|
||||
runtime.ReadMemStats(&after)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"before": map[string]interface{}{
|
||||
"alloc": before.Alloc,
|
||||
"total_alloc": before.TotalAlloc,
|
||||
"sys": before.Sys,
|
||||
"num_gc": before.NumGC,
|
||||
},
|
||||
"after": map[string]interface{}{
|
||||
"alloc": after.Alloc,
|
||||
"total_alloc": after.TotalAlloc,
|
||||
"sys": after.Sys,
|
||||
"num_gc": after.NumGC,
|
||||
},
|
||||
"freed": before.Alloc - after.Alloc,
|
||||
})
|
||||
}
|
||||
64
internal/httpapp/docs_handlers.go
Normal file
64
internal/httpapp/docs_handlers.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package httpapp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// handleAPIDocs serves the API documentation page
|
||||
func (a *App) handleAPIDocs(w http.ResponseWriter, r *http.Request) {
|
||||
// Simple HTML page with Swagger UI
|
||||
html := `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>AtlasOS API Documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@5.10.3/swagger-ui.css" />
|
||||
<style>
|
||||
html { box-sizing: border-box; overflow: -moz-scrollbars-vertical; overflow-y: scroll; }
|
||||
*, *:before, *:after { box-sizing: inherit; }
|
||||
body { margin:0; background: #fafafa; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5.10.3/swagger-ui-bundle.js"></script>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5.10.3/swagger-ui-standalone-preset.js"></script>
|
||||
<script>
|
||||
window.onload = function() {
|
||||
const ui = SwaggerUIBundle({
|
||||
url: "/api/openapi.yaml",
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset
|
||||
],
|
||||
plugins: [
|
||||
SwaggerUIBundle.plugins.DownloadUrl
|
||||
],
|
||||
layout: "StandaloneLayout"
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(html))
|
||||
}
|
||||
|
||||
// handleOpenAPISpec serves the OpenAPI specification
|
||||
func (a *App) handleOpenAPISpec(w http.ResponseWriter, r *http.Request) {
|
||||
// Read OpenAPI spec from file system
|
||||
specPath := filepath.Join("docs", "openapi.yaml")
|
||||
spec, err := os.ReadFile(specPath)
|
||||
if err != nil {
|
||||
http.Error(w, "OpenAPI spec not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(spec)
|
||||
}
|
||||
59
internal/httpapp/error_handlers.go
Normal file
59
internal/httpapp/error_handlers.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package httpapp
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/errors"
|
||||
)
|
||||
|
||||
// writeError writes a structured error response
|
||||
func writeError(w http.ResponseWriter, err error) {
|
||||
// Check if it's an APIError
|
||||
if apiErr, ok := err.(*errors.APIError); ok {
|
||||
writeJSON(w, apiErr.HTTPStatus, apiErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Default to internal server error
|
||||
log.Printf("unhandled error: %v", err)
|
||||
apiErr := errors.ErrInternal("an unexpected error occurred")
|
||||
writeJSON(w, apiErr.HTTPStatus, apiErr)
|
||||
}
|
||||
|
||||
// handleServiceError handles errors from service operations with graceful degradation
|
||||
func (a *App) handleServiceError(serviceName string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Log the error for debugging
|
||||
log.Printf("%s service error: %v", serviceName, err)
|
||||
|
||||
// For service errors, we might want to continue operation
|
||||
// but log the issue. The API request can still succeed
|
||||
// even if service configuration fails (desired state is stored)
|
||||
return errors.ErrServiceUnavailable(serviceName).WithDetails(err.Error())
|
||||
}
|
||||
|
||||
// recoverPanic recovers from panics and returns a proper error response
|
||||
func recoverPanic(w http.ResponseWriter, r *http.Request) {
|
||||
if rec := recover(); rec != nil {
|
||||
log.Printf("panic recovered: %v", rec)
|
||||
err := errors.ErrInternal("an unexpected error occurred")
|
||||
writeError(w, err)
|
||||
}
|
||||
}
|
||||
|
||||
// errorMiddleware wraps handlers with panic recovery
|
||||
func (a *App) errorMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer recoverPanic(w, r)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// writeJSONError is a convenience function for JSON error responses
|
||||
func writeJSONError(w http.ResponseWriter, code int, message string) {
|
||||
writeJSON(w, code, map[string]string{"error": message})
|
||||
}
|
||||
166
internal/httpapp/handlers.go
Normal file
166
internal/httpapp/handlers.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package httpapp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]any{
|
||||
"Title": "Dashboard",
|
||||
"Build": map[string]string{
|
||||
"version": "v0.1.0-dev",
|
||||
},
|
||||
}
|
||||
a.render(w, "dashboard.html", data)
|
||||
}
|
||||
|
||||
func (a *App) handleStorage(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]any{
|
||||
"Title": "Storage Management",
|
||||
"Build": map[string]string{
|
||||
"version": "v0.1.0-dev",
|
||||
},
|
||||
"ContentTemplate": "storage-content",
|
||||
}
|
||||
a.render(w, "storage.html", data)
|
||||
}
|
||||
|
||||
func (a *App) handleShares(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]any{
|
||||
"Title": "Storage Shares",
|
||||
"Build": map[string]string{
|
||||
"version": "v0.1.0-dev",
|
||||
},
|
||||
"ContentTemplate": "shares-content",
|
||||
}
|
||||
a.render(w, "shares.html", data)
|
||||
}
|
||||
|
||||
func (a *App) handleISCSI(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]any{
|
||||
"Title": "iSCSI Targets",
|
||||
"Build": map[string]string{
|
||||
"version": "v0.1.0-dev",
|
||||
},
|
||||
"ContentTemplate": "iscsi-content",
|
||||
}
|
||||
a.render(w, "iscsi.html", data)
|
||||
}
|
||||
|
||||
func (a *App) handleProtection(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]any{
|
||||
"Title": "Data Protection",
|
||||
"Build": map[string]string{
|
||||
"version": "v0.1.0-dev",
|
||||
},
|
||||
"ContentTemplate": "protection-content",
|
||||
}
|
||||
a.render(w, "protection.html", data)
|
||||
}
|
||||
|
||||
func (a *App) handleManagement(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]any{
|
||||
"Title": "System Management",
|
||||
"Build": map[string]string{
|
||||
"version": "v0.1.0-dev",
|
||||
},
|
||||
"ContentTemplate": "management-content",
|
||||
}
|
||||
a.render(w, "management.html", data)
|
||||
}
|
||||
|
||||
func (a *App) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]any{
|
||||
"Title": "Login",
|
||||
"Build": map[string]string{
|
||||
"version": "v0.1.0-dev",
|
||||
},
|
||||
"ContentTemplate": "login-content",
|
||||
}
|
||||
a.render(w, "login.html", data)
|
||||
}
|
||||
|
||||
func (a *App) handleHealthz(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := r.Context().Value(requestIDKey).(string)
|
||||
resp := map[string]any{
|
||||
"status": "ok",
|
||||
"ts": id, // request ID for correlation
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (a *App) handleMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
// Collect real-time metrics
|
||||
// ZFS metrics
|
||||
pools, _ := a.zfs.ListPools()
|
||||
datasets, _ := a.zfs.ListDatasets("")
|
||||
zvols, _ := a.zfs.ListZVOLs("")
|
||||
snapshots, _ := a.zfs.ListSnapshots("")
|
||||
|
||||
a.metricsCollector.UpdateZFSMetrics(pools, datasets, zvols, snapshots)
|
||||
|
||||
// Service metrics
|
||||
smbShares := a.smbStore.List()
|
||||
nfsExports := a.nfsStore.List()
|
||||
iscsiTargets := a.iscsiStore.List()
|
||||
|
||||
smbStatus, _ := a.smbService.GetStatus()
|
||||
nfsStatus, _ := a.nfsService.GetStatus()
|
||||
iscsiStatus, _ := a.iscsiService.GetStatus()
|
||||
|
||||
a.metricsCollector.UpdateServiceMetrics(
|
||||
len(smbShares),
|
||||
len(nfsExports),
|
||||
len(iscsiTargets),
|
||||
smbStatus,
|
||||
nfsStatus,
|
||||
iscsiStatus,
|
||||
)
|
||||
|
||||
// Job metrics
|
||||
allJobs := a.jobManager.List("")
|
||||
running := 0
|
||||
completed := 0
|
||||
failed := 0
|
||||
for _, job := range allJobs {
|
||||
switch job.Status {
|
||||
case "running":
|
||||
running++
|
||||
case "completed":
|
||||
completed++
|
||||
case "failed":
|
||||
failed++
|
||||
}
|
||||
}
|
||||
|
||||
a.metricsCollector.UpdateJobMetrics(len(allJobs), running, completed, failed)
|
||||
|
||||
// Update uptime
|
||||
a.metricsCollector.SetUptime(int64(time.Since(a.startTime).Seconds()))
|
||||
|
||||
// Output Prometheus format
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(a.metricsCollector.Collect()))
|
||||
}
|
||||
|
||||
func (a *App) render(w http.ResponseWriter, name string, data any) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
// base.html defines layout; dashboard.html will invoke it via template inheritance style.
|
||||
if err := a.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
||||
log.Printf("template render error: %v", err)
|
||||
http.Error(w, "template render error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
log.Printf("json encode error: %v", err)
|
||||
}
|
||||
}
|
||||
76
internal/httpapp/https_middleware.go
Normal file
76
internal/httpapp/https_middleware.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package httpapp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/errors"
|
||||
)
|
||||
|
||||
// httpsEnforcementMiddleware enforces HTTPS connections
|
||||
func (a *App) httpsEnforcementMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Skip HTTPS enforcement for health checks and localhost
|
||||
if a.isPublicEndpoint(r.URL.Path, r.Method) || isLocalhost(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// If TLS is enabled, enforce HTTPS
|
||||
if a.tlsConfig != nil && a.tlsConfig.Enabled {
|
||||
// Check if request is already over HTTPS
|
||||
if r.TLS != nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Check X-Forwarded-Proto header (for reverse proxies)
|
||||
if r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect HTTP to HTTPS
|
||||
httpsURL := "https://" + r.Host + r.URL.RequestURI()
|
||||
http.Redirect(w, r, httpsURL, http.StatusMovedPermanently)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// isLocalhost checks if the request is from localhost
|
||||
func isLocalhost(r *http.Request) bool {
|
||||
host := r.Host
|
||||
if strings.Contains(host, ":") {
|
||||
host = strings.Split(host, ":")[0]
|
||||
}
|
||||
return host == "localhost" || host == "127.0.0.1" || host == "::1"
|
||||
}
|
||||
|
||||
// requireHTTPSMiddleware requires HTTPS for all requests (strict mode)
|
||||
func (a *App) requireHTTPSMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Skip for health checks
|
||||
if a.isPublicEndpoint(r.URL.Path, r.Method) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// If TLS is enabled, require HTTPS
|
||||
if a.tlsConfig != nil && a.tlsConfig.Enabled {
|
||||
// Check if request is over HTTPS
|
||||
if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" {
|
||||
writeError(w, errors.NewAPIError(
|
||||
errors.ErrCodeForbidden,
|
||||
"HTTPS required",
|
||||
http.StatusForbidden,
|
||||
).WithDetails("this endpoint requires HTTPS"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
162
internal/httpapp/maintenance_handlers.go
Normal file
162
internal/httpapp/maintenance_handlers.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package httpapp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/backup"
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/errors"
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/models"
|
||||
)
|
||||
|
||||
// handleGetMaintenanceStatus returns the current maintenance mode status
|
||||
func (a *App) handleGetMaintenanceStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if a.maintenanceService == nil {
|
||||
writeError(w, errors.NewAPIError(
|
||||
errors.ErrCodeInternal,
|
||||
"maintenance service not available",
|
||||
http.StatusInternalServerError,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
status := a.maintenanceService.GetStatus()
|
||||
writeJSON(w, http.StatusOK, status)
|
||||
}
|
||||
|
||||
// handleEnableMaintenance enables maintenance mode
|
||||
func (a *App) handleEnableMaintenance(w http.ResponseWriter, r *http.Request) {
|
||||
if a.maintenanceService == nil {
|
||||
writeError(w, errors.NewAPIError(
|
||||
errors.ErrCodeInternal,
|
||||
"maintenance service not available",
|
||||
http.StatusInternalServerError,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// Require administrator role
|
||||
user, ok := getUserFromContext(r)
|
||||
if !ok {
|
||||
writeError(w, errors.NewAPIError(
|
||||
errors.ErrCodeUnauthorized,
|
||||
"authentication required",
|
||||
http.StatusUnauthorized,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
if user.Role != models.RoleAdministrator {
|
||||
writeError(w, errors.NewAPIError(
|
||||
errors.ErrCodeForbidden,
|
||||
"administrator role required",
|
||||
http.StatusForbidden,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Reason string `json:"reason"`
|
||||
AllowedUsers []string `json:"allowed_users,omitempty"`
|
||||
CreateBackup bool `json:"create_backup,omitempty"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
// Description is optional, so we'll continue even if body is empty
|
||||
_ = err
|
||||
}
|
||||
|
||||
// Create backup before entering maintenance if requested
|
||||
var backupID string
|
||||
if req.CreateBackup && a.backupService != nil {
|
||||
// Collect all configuration data
|
||||
backupData := backup.BackupData{
|
||||
Users: a.userStore.List(),
|
||||
SMBShares: a.smbStore.List(),
|
||||
NFSExports: a.nfsStore.List(),
|
||||
ISCSITargets: a.iscsiStore.List(),
|
||||
Policies: a.snapshotPolicy.List(),
|
||||
Config: map[string]interface{}{
|
||||
"database_path": a.cfg.DatabasePath,
|
||||
},
|
||||
}
|
||||
|
||||
id, err := a.backupService.CreateBackup(backupData, "Automatic backup before maintenance mode")
|
||||
if err != nil {
|
||||
writeError(w, errors.NewAPIError(
|
||||
errors.ErrCodeInternal,
|
||||
"failed to create backup",
|
||||
http.StatusInternalServerError,
|
||||
).WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
backupID = id
|
||||
}
|
||||
|
||||
// Enable maintenance mode
|
||||
if err := a.maintenanceService.Enable(user.ID, req.Reason, req.AllowedUsers); err != nil {
|
||||
writeError(w, errors.NewAPIError(
|
||||
errors.ErrCodeInternal,
|
||||
"failed to enable maintenance mode",
|
||||
http.StatusInternalServerError,
|
||||
).WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Set backup ID if created
|
||||
if backupID != "" {
|
||||
a.maintenanceService.SetLastBackupID(backupID)
|
||||
}
|
||||
|
||||
status := a.maintenanceService.GetStatus()
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "maintenance mode enabled",
|
||||
"status": status,
|
||||
"backup_id": backupID,
|
||||
})
|
||||
}
|
||||
|
||||
// handleDisableMaintenance disables maintenance mode
|
||||
func (a *App) handleDisableMaintenance(w http.ResponseWriter, r *http.Request) {
|
||||
if a.maintenanceService == nil {
|
||||
writeError(w, errors.NewAPIError(
|
||||
errors.ErrCodeInternal,
|
||||
"maintenance service not available",
|
||||
http.StatusInternalServerError,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// Require administrator role
|
||||
user, ok := getUserFromContext(r)
|
||||
if !ok {
|
||||
writeError(w, errors.NewAPIError(
|
||||
errors.ErrCodeUnauthorized,
|
||||
"authentication required",
|
||||
http.StatusUnauthorized,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
if user.Role != models.RoleAdministrator {
|
||||
writeError(w, errors.NewAPIError(
|
||||
errors.ErrCodeForbidden,
|
||||
"administrator role required",
|
||||
http.StatusForbidden,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
if err := a.maintenanceService.Disable(user.ID); err != nil {
|
||||
writeError(w, errors.NewAPIError(
|
||||
errors.ErrCodeInternal,
|
||||
"failed to disable maintenance mode",
|
||||
http.StatusInternalServerError,
|
||||
).WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"message": "maintenance mode disabled",
|
||||
})
|
||||
}
|
||||
39
internal/httpapp/maintenance_middleware.go
Normal file
39
internal/httpapp/maintenance_middleware.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package httpapp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/errors"
|
||||
)
|
||||
|
||||
// maintenanceMiddleware blocks operations during maintenance mode
|
||||
func (a *App) maintenanceMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Skip maintenance check for read-only operations and public endpoints
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if a.isPublicEndpoint(r.URL.Path, r.Method) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if maintenance mode is enabled
|
||||
if a.maintenanceService != nil && a.maintenanceService.IsEnabled() {
|
||||
// Check if user is allowed during maintenance
|
||||
user, ok := getUserFromContext(r)
|
||||
if !ok || !a.maintenanceService.IsUserAllowed(user.ID) {
|
||||
writeError(w, errors.NewAPIError(
|
||||
errors.ErrCodeServiceUnavailable,
|
||||
"system is in maintenance mode",
|
||||
http.StatusServiceUnavailable,
|
||||
).WithDetails("the system is currently in maintenance mode and user operations are disabled"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
69
internal/httpapp/middleware.go
Normal file
69
internal/httpapp/middleware.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package httpapp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ctxKey string
|
||||
|
||||
const requestIDKey ctxKey = "reqid"
|
||||
|
||||
func requestID(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.Header.Get("X-Request-Id")
|
||||
if id == "" {
|
||||
id = newReqID()
|
||||
}
|
||||
w.Header().Set("X-Request-Id", id)
|
||||
ctx := context.WithValue(r.Context(), requestIDKey, id)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func logging(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
// Create response writer wrapper to capture status code
|
||||
rw := &responseWriterWrapper{
|
||||
ResponseWriter: w,
|
||||
statusCode: http.StatusOK,
|
||||
}
|
||||
|
||||
next.ServeHTTP(rw, r)
|
||||
|
||||
d := time.Since(start)
|
||||
id, _ := r.Context().Value(requestIDKey).(string)
|
||||
|
||||
// Use structured logging if available, otherwise fallback to standard log
|
||||
log.Printf("%s %s %s status=%d rid=%s dur=%s",
|
||||
r.RemoteAddr, r.Method, r.URL.Path, rw.statusCode, id, d)
|
||||
})
|
||||
}
|
||||
|
||||
// responseWriterWrapper wraps http.ResponseWriter to capture status code
|
||||
// Note: This is different from the one in audit_middleware.go to avoid conflicts
|
||||
type responseWriterWrapper struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (rw *responseWriterWrapper) WriteHeader(code int) {
|
||||
rw.statusCode = code
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func newReqID() string {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
// Fallback to timestamp-based ID if crypto/rand fails (extremely rare)
|
||||
log.Printf("rand.Read failed, using fallback: %v", err)
|
||||
return hex.EncodeToString([]byte(time.Now().Format(time.RFC3339Nano)))
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
165
internal/httpapp/rate_limit.go
Normal file
165
internal/httpapp/rate_limit.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package httpapp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/errors"
|
||||
)
|
||||
|
||||
// RateLimiter implements token bucket rate limiting
|
||||
type RateLimiter struct {
|
||||
mu sync.RWMutex
|
||||
clients map[string]*clientLimiter
|
||||
rate int // requests per window
|
||||
window time.Duration // time window
|
||||
cleanupTick *time.Ticker
|
||||
stopCleanup chan struct{}
|
||||
}
|
||||
|
||||
type clientLimiter struct {
|
||||
tokens int
|
||||
lastUpdate time.Time
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewRateLimiter creates a new rate limiter
|
||||
func NewRateLimiter(rate int, window time.Duration) *RateLimiter {
|
||||
rl := &RateLimiter{
|
||||
clients: make(map[string]*clientLimiter),
|
||||
rate: rate,
|
||||
window: window,
|
||||
cleanupTick: time.NewTicker(5 * time.Minute),
|
||||
stopCleanup: make(chan struct{}),
|
||||
}
|
||||
|
||||
// Start cleanup goroutine
|
||||
go rl.cleanup()
|
||||
|
||||
return rl
|
||||
}
|
||||
|
||||
// cleanup periodically removes old client limiters
|
||||
func (rl *RateLimiter) cleanup() {
|
||||
for {
|
||||
select {
|
||||
case <-rl.cleanupTick.C:
|
||||
rl.mu.Lock()
|
||||
now := time.Now()
|
||||
for key, limiter := range rl.clients {
|
||||
limiter.mu.Lock()
|
||||
// Remove if last update was more than 2 windows ago
|
||||
if now.Sub(limiter.lastUpdate) > rl.window*2 {
|
||||
delete(rl.clients, key)
|
||||
}
|
||||
limiter.mu.Unlock()
|
||||
}
|
||||
rl.mu.Unlock()
|
||||
case <-rl.stopCleanup:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop stops the cleanup goroutine
|
||||
func (rl *RateLimiter) Stop() {
|
||||
rl.cleanupTick.Stop()
|
||||
close(rl.stopCleanup)
|
||||
}
|
||||
|
||||
// Allow checks if a request from the given key should be allowed
|
||||
func (rl *RateLimiter) Allow(key string) bool {
|
||||
rl.mu.Lock()
|
||||
limiter, exists := rl.clients[key]
|
||||
if !exists {
|
||||
limiter = &clientLimiter{
|
||||
tokens: rl.rate,
|
||||
lastUpdate: time.Now(),
|
||||
}
|
||||
rl.clients[key] = limiter
|
||||
}
|
||||
rl.mu.Unlock()
|
||||
|
||||
limiter.mu.Lock()
|
||||
defer limiter.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
elapsed := now.Sub(limiter.lastUpdate)
|
||||
|
||||
// Refill tokens based on elapsed time
|
||||
if elapsed >= rl.window {
|
||||
// Full refill
|
||||
limiter.tokens = rl.rate
|
||||
} else {
|
||||
// Partial refill based on elapsed time
|
||||
tokensToAdd := int(float64(rl.rate) * elapsed.Seconds() / rl.window.Seconds())
|
||||
if tokensToAdd > 0 {
|
||||
limiter.tokens = min(limiter.tokens+tokensToAdd, rl.rate)
|
||||
}
|
||||
}
|
||||
|
||||
limiter.lastUpdate = now
|
||||
|
||||
// Check if we have tokens
|
||||
if limiter.tokens > 0 {
|
||||
limiter.tokens--
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// getClientKey extracts a key for rate limiting from the request
|
||||
func getClientKey(r *http.Request) string {
|
||||
// Try to get IP address
|
||||
ip := getClientIP(r)
|
||||
|
||||
// If authenticated, use user ID for more granular limiting
|
||||
if user, ok := getUserFromContext(r); ok {
|
||||
return "user:" + user.ID
|
||||
}
|
||||
|
||||
return "ip:" + ip
|
||||
}
|
||||
|
||||
// rateLimitMiddleware implements rate limiting
|
||||
func (a *App) rateLimitMiddleware(next http.Handler) http.Handler {
|
||||
// Default: 100 requests per minute per client
|
||||
rateLimiter := NewRateLimiter(100, time.Minute)
|
||||
|
||||
// Store limiter for cleanup on shutdown
|
||||
// Note: Cleanup will be handled by the limiter's own cleanup goroutine
|
||||
_ = rateLimiter
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Skip rate limiting for public endpoints
|
||||
if a.isPublicEndpoint(r.URL.Path, r.Method) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
key := getClientKey(r)
|
||||
if !rateLimiter.Allow(key) {
|
||||
writeError(w, errors.NewAPIError(
|
||||
errors.ErrCodeServiceUnavailable,
|
||||
"rate limit exceeded",
|
||||
http.StatusTooManyRequests,
|
||||
).WithDetails("too many requests, please try again later"))
|
||||
return
|
||||
}
|
||||
|
||||
// Add rate limit headers
|
||||
w.Header().Set("X-RateLimit-Limit", "100")
|
||||
w.Header().Set("X-RateLimit-Window", "60")
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
267
internal/httpapp/router_helpers.go
Normal file
267
internal/httpapp/router_helpers.go
Normal file
@@ -0,0 +1,267 @@
|
||||
package httpapp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/errors"
|
||||
)
|
||||
|
||||
// methodHandler routes requests based on HTTP method
|
||||
func methodHandler(get, post, put, delete, patch http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
if get != nil {
|
||||
get(w, r)
|
||||
} else {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
case http.MethodPost:
|
||||
if post != nil {
|
||||
post(w, r)
|
||||
} else {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
case http.MethodPut:
|
||||
if put != nil {
|
||||
put(w, r)
|
||||
} else {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
case http.MethodDelete:
|
||||
if delete != nil {
|
||||
delete(w, r)
|
||||
} else {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
case http.MethodPatch:
|
||||
if patch != nil {
|
||||
patch(w, r)
|
||||
} else {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pathParam extracts the last segment from a path
|
||||
func pathParam(r *http.Request, prefix string) string {
|
||||
path := strings.TrimPrefix(r.URL.Path, prefix)
|
||||
path = strings.Trim(path, "/")
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) > 0 {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// handlePoolOps routes pool operations by method
|
||||
func (a *App) handlePoolOps(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract pool name from path like /api/v1/pools/tank
|
||||
name := pathParam(r, "/api/v1/pools/")
|
||||
if name == "" {
|
||||
writeError(w, errors.ErrBadRequest("pool name required"))
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasSuffix(r.URL.Path, "/scrub") {
|
||||
if r.Method == http.MethodPost {
|
||||
a.handleScrubPool(w, r)
|
||||
} else if r.Method == http.MethodGet {
|
||||
a.handleGetScrubStatus(w, r)
|
||||
} else {
|
||||
writeError(w, errors.NewAPIError(errors.ErrCodeBadRequest, "method not allowed", http.StatusMethodNotAllowed))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasSuffix(r.URL.Path, "/export") {
|
||||
if r.Method == http.MethodPost {
|
||||
a.handleExportPool(w, r)
|
||||
} else {
|
||||
writeError(w, errors.NewAPIError(errors.ErrCodeBadRequest, "method not allowed", http.StatusMethodNotAllowed))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleGetPool(w, r) },
|
||||
nil,
|
||||
nil,
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleDeletePool(w, r) },
|
||||
nil,
|
||||
)(w, r)
|
||||
}
|
||||
|
||||
// handleDatasetOps routes dataset operations by method
|
||||
func (a *App) handleDatasetOps(w http.ResponseWriter, r *http.Request) {
|
||||
methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleGetDataset(w, r) },
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleCreateDataset(w, r) },
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleUpdateDataset(w, r) },
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleDeleteDataset(w, r) },
|
||||
nil,
|
||||
)(w, r)
|
||||
}
|
||||
|
||||
// handleZVOLOps routes ZVOL operations by method
|
||||
func (a *App) handleZVOLOps(w http.ResponseWriter, r *http.Request) {
|
||||
methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleGetZVOL(w, r) },
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleCreateZVOL(w, r) },
|
||||
nil,
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleDeleteZVOL(w, r) },
|
||||
nil,
|
||||
)(w, r)
|
||||
}
|
||||
|
||||
// handleSnapshotOps routes snapshot operations by method
|
||||
func (a *App) handleSnapshotOps(w http.ResponseWriter, r *http.Request) {
|
||||
methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleGetSnapshot(w, r) },
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleCreateSnapshot(w, r) },
|
||||
nil,
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleDeleteSnapshot(w, r) },
|
||||
nil,
|
||||
)(w, r)
|
||||
}
|
||||
|
||||
// handleSnapshotPolicyOps routes snapshot policy operations by method
|
||||
func (a *App) handleSnapshotPolicyOps(w http.ResponseWriter, r *http.Request) {
|
||||
methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleGetSnapshotPolicy(w, r) },
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleCreateSnapshotPolicy(w, r) },
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleUpdateSnapshotPolicy(w, r) },
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleDeleteSnapshotPolicy(w, r) },
|
||||
nil,
|
||||
)(w, r)
|
||||
}
|
||||
|
||||
// handleSMBShareOps routes SMB share operations by method
|
||||
func (a *App) handleSMBShareOps(w http.ResponseWriter, r *http.Request) {
|
||||
methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleGetSMBShare(w, r) },
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleCreateSMBShare(w, r) },
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleUpdateSMBShare(w, r) },
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleDeleteSMBShare(w, r) },
|
||||
nil,
|
||||
)(w, r)
|
||||
}
|
||||
|
||||
// handleNFSExportOps routes NFS export operations by method
|
||||
func (a *App) handleNFSExportOps(w http.ResponseWriter, r *http.Request) {
|
||||
methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleGetNFSExport(w, r) },
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleCreateNFSExport(w, r) },
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleUpdateNFSExport(w, r) },
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleDeleteNFSExport(w, r) },
|
||||
nil,
|
||||
)(w, r)
|
||||
}
|
||||
|
||||
// handleISCSITargetOps routes iSCSI target operations by method
|
||||
func (a *App) handleBackupOps(w http.ResponseWriter, r *http.Request) {
|
||||
backupID := pathParam(r, "/api/v1/backups/")
|
||||
if backupID == "" {
|
||||
writeError(w, errors.ErrBadRequest("backup id required"))
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
// Check if it's a verify request
|
||||
if r.URL.Query().Get("verify") == "true" {
|
||||
a.handleVerifyBackup(w, r)
|
||||
} else {
|
||||
a.handleGetBackup(w, r)
|
||||
}
|
||||
case http.MethodPost:
|
||||
// Restore backup (POST /api/v1/backups/{id}/restore)
|
||||
if strings.HasSuffix(r.URL.Path, "/restore") {
|
||||
a.handleRestoreBackup(w, r)
|
||||
} else {
|
||||
writeError(w, errors.ErrBadRequest("invalid backup operation"))
|
||||
}
|
||||
case http.MethodDelete:
|
||||
a.handleDeleteBackup(w, r)
|
||||
default:
|
||||
writeError(w, errors.ErrBadRequest("method not allowed"))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleISCSITargetOps(w http.ResponseWriter, r *http.Request) {
|
||||
id := pathParam(r, "/api/v1/iscsi/targets/")
|
||||
if id == "" {
|
||||
writeError(w, errors.ErrBadRequest("target id required"))
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasSuffix(r.URL.Path, "/connection") {
|
||||
if r.Method == http.MethodGet {
|
||||
a.handleGetISCSIConnectionInstructions(w, r)
|
||||
} else {
|
||||
writeError(w, errors.NewAPIError(errors.ErrCodeBadRequest, "method not allowed", http.StatusMethodNotAllowed))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasSuffix(r.URL.Path, "/luns") {
|
||||
if r.Method == http.MethodPost {
|
||||
a.handleAddLUN(w, r)
|
||||
return
|
||||
}
|
||||
writeError(w, errors.NewAPIError(errors.ErrCodeBadRequest, "method not allowed", http.StatusMethodNotAllowed))
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasSuffix(r.URL.Path, "/luns/remove") {
|
||||
if r.Method == http.MethodPost {
|
||||
a.handleRemoveLUN(w, r)
|
||||
return
|
||||
}
|
||||
writeError(w, errors.NewAPIError(errors.ErrCodeBadRequest, "method not allowed", http.StatusMethodNotAllowed))
|
||||
return
|
||||
}
|
||||
|
||||
methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleGetISCSITarget(w, r) },
|
||||
nil,
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleUpdateISCSITarget(w, r) },
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleDeleteISCSITarget(w, r) },
|
||||
nil,
|
||||
)(w, r)
|
||||
}
|
||||
|
||||
// handleJobOps routes job operations by method
|
||||
func (a *App) handleJobOps(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/cancel") {
|
||||
if r.Method == http.MethodPost {
|
||||
a.handleCancelJob(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleGetJob(w, r) },
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)(w, r)
|
||||
}
|
||||
|
||||
// handleUserOps routes user operations by method
|
||||
func (a *App) handleUserOps(w http.ResponseWriter, r *http.Request) {
|
||||
methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleGetUser(w, r) },
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleCreateUser(w, r) },
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleUpdateUser(w, r) },
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleDeleteUser(w, r) },
|
||||
nil,
|
||||
)(w, r)
|
||||
}
|
||||
239
internal/httpapp/routes.go
Normal file
239
internal/httpapp/routes.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package httpapp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/models"
|
||||
)
|
||||
|
||||
func (a *App) routes() {
|
||||
// Static files
|
||||
fs := http.FileServer(http.Dir(a.cfg.StaticDir))
|
||||
a.mux.Handle("/static/", http.StripPrefix("/static/", fs))
|
||||
|
||||
// Web UI
|
||||
a.mux.HandleFunc("/", a.handleDashboard)
|
||||
a.mux.HandleFunc("/login", a.handleLoginPage)
|
||||
a.mux.HandleFunc("/storage", a.handleStorage)
|
||||
a.mux.HandleFunc("/shares", a.handleShares)
|
||||
a.mux.HandleFunc("/iscsi", a.handleISCSI)
|
||||
a.mux.HandleFunc("/protection", a.handleProtection)
|
||||
a.mux.HandleFunc("/management", a.handleManagement)
|
||||
|
||||
// Health & metrics
|
||||
a.mux.HandleFunc("/healthz", a.handleHealthz)
|
||||
a.mux.HandleFunc("/health", a.handleHealthCheck) // Detailed health check
|
||||
a.mux.HandleFunc("/metrics", a.handleMetrics)
|
||||
|
||||
// Diagnostics
|
||||
a.mux.HandleFunc("/api/v1/system/info", methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleSystemInfo(w, r) },
|
||||
nil, nil, nil, nil,
|
||||
))
|
||||
a.mux.HandleFunc("/api/v1/system/logs", methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleLogs(w, r) },
|
||||
nil, nil, nil, nil,
|
||||
))
|
||||
a.mux.HandleFunc("/api/v1/system/gc", methodHandler(
|
||||
nil,
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleGC(w, r) },
|
||||
nil, nil, nil,
|
||||
))
|
||||
|
||||
// Maintenance Mode (requires authentication, admin-only for enable/disable)
|
||||
a.mux.HandleFunc("/api/v1/maintenance", methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleGetMaintenanceStatus(w, r) },
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
adminRole := models.RoleAdministrator
|
||||
a.requireRole(adminRole)(http.HandlerFunc(a.handleEnableMaintenance)).ServeHTTP(w, r)
|
||||
},
|
||||
nil, nil, nil,
|
||||
))
|
||||
a.mux.HandleFunc("/api/v1/maintenance/disable", methodHandler(
|
||||
nil,
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
adminRole := models.RoleAdministrator
|
||||
a.requireRole(adminRole)(http.HandlerFunc(a.handleDisableMaintenance)).ServeHTTP(w, r)
|
||||
},
|
||||
nil, nil, nil,
|
||||
))
|
||||
|
||||
// API Documentation
|
||||
a.mux.HandleFunc("/api/docs", a.handleAPIDocs)
|
||||
a.mux.HandleFunc("/api/openapi.yaml", a.handleOpenAPISpec)
|
||||
|
||||
// Backup & Restore
|
||||
a.mux.HandleFunc("/api/v1/backups", methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleListBackups(w, r) },
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleCreateBackup(w, r) },
|
||||
nil, nil, nil,
|
||||
))
|
||||
a.mux.HandleFunc("/api/v1/backups/", a.handleBackupOps)
|
||||
|
||||
// Dashboard API
|
||||
a.mux.HandleFunc("/api/v1/dashboard", methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleDashboardAPI(w, r) },
|
||||
nil, nil, nil, nil,
|
||||
))
|
||||
|
||||
// API v1 routes - ZFS Management
|
||||
a.mux.HandleFunc("/api/v1/disks", methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleListDisks(w, r) },
|
||||
nil, nil, nil, nil,
|
||||
))
|
||||
a.mux.HandleFunc("/api/v1/pools", methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleListPools(w, r) },
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleCreatePool(w, r) },
|
||||
nil, nil, nil,
|
||||
))
|
||||
a.mux.HandleFunc("/api/v1/pools/available", methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleListAvailablePools(w, r) },
|
||||
nil, nil, nil, nil,
|
||||
))
|
||||
a.mux.HandleFunc("/api/v1/pools/import", methodHandler(
|
||||
nil,
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleImportPool(w, r) },
|
||||
nil, nil, nil,
|
||||
))
|
||||
a.mux.HandleFunc("/api/v1/pools/", a.handlePoolOps)
|
||||
|
||||
a.mux.HandleFunc("/api/v1/datasets", methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleListDatasets(w, r) },
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleCreateDataset(w, r) },
|
||||
nil, nil, nil,
|
||||
))
|
||||
a.mux.HandleFunc("/api/v1/datasets/", a.handleDatasetOps)
|
||||
|
||||
a.mux.HandleFunc("/api/v1/zvols", methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleListZVOLs(w, r) },
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleCreateZVOL(w, r) },
|
||||
nil, nil, nil,
|
||||
))
|
||||
a.mux.HandleFunc("/api/v1/zvols/", a.handleZVOLOps)
|
||||
|
||||
// Snapshot Management
|
||||
a.mux.HandleFunc("/api/v1/snapshots", methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleListSnapshots(w, r) },
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleCreateSnapshot(w, r) },
|
||||
nil, nil, nil,
|
||||
))
|
||||
a.mux.HandleFunc("/api/v1/snapshots/", a.handleSnapshotOps)
|
||||
a.mux.HandleFunc("/api/v1/snapshot-policies", methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleListSnapshotPolicies(w, r) },
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleCreateSnapshotPolicy(w, r) },
|
||||
nil, nil, nil,
|
||||
))
|
||||
a.mux.HandleFunc("/api/v1/snapshot-policies/", a.handleSnapshotPolicyOps)
|
||||
|
||||
// Storage Services - SMB
|
||||
a.mux.HandleFunc("/api/v1/shares/smb", methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleListSMBShares(w, r) },
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleCreateSMBShare(w, r) },
|
||||
nil, nil, nil,
|
||||
))
|
||||
a.mux.HandleFunc("/api/v1/shares/smb/", a.handleSMBShareOps)
|
||||
|
||||
// Storage Services - NFS
|
||||
a.mux.HandleFunc("/api/v1/exports/nfs", methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleListNFSExports(w, r) },
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleCreateNFSExport(w, r) },
|
||||
nil, nil, nil,
|
||||
))
|
||||
a.mux.HandleFunc("/api/v1/exports/nfs/", a.handleNFSExportOps)
|
||||
|
||||
// Storage Services - iSCSI
|
||||
a.mux.HandleFunc("/api/v1/iscsi/targets", methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleListISCSITargets(w, r) },
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleCreateISCSITarget(w, r) },
|
||||
nil, nil, nil,
|
||||
))
|
||||
a.mux.HandleFunc("/api/v1/iscsi/targets/", a.handleISCSITargetOps)
|
||||
|
||||
// Job Management
|
||||
a.mux.HandleFunc("/api/v1/jobs", methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleListJobs(w, r) },
|
||||
nil, nil, nil, nil,
|
||||
))
|
||||
a.mux.HandleFunc("/api/v1/jobs/", a.handleJobOps)
|
||||
|
||||
// Authentication & Authorization (public endpoints)
|
||||
a.mux.HandleFunc("/api/v1/auth/login", methodHandler(
|
||||
nil,
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleLogin(w, r) },
|
||||
nil, nil, nil,
|
||||
))
|
||||
a.mux.HandleFunc("/api/v1/auth/logout", methodHandler(
|
||||
nil,
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleLogout(w, r) },
|
||||
nil, nil, nil,
|
||||
))
|
||||
|
||||
// User Management (requires authentication, admin-only for create/update/delete)
|
||||
a.mux.HandleFunc("/api/v1/users", methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleListUsers(w, r) },
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
adminRole := models.RoleAdministrator
|
||||
a.requireRole(adminRole)(http.HandlerFunc(a.handleCreateUser)).ServeHTTP(w, r)
|
||||
},
|
||||
nil, nil, nil,
|
||||
))
|
||||
a.mux.HandleFunc("/api/v1/users/", a.handleUserOpsWithAuth)
|
||||
|
||||
// Service Management (requires authentication, admin-only)
|
||||
a.mux.HandleFunc("/api/v1/services", methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
adminRole := models.RoleAdministrator
|
||||
a.requireRole(adminRole)(http.HandlerFunc(a.handleListServices)).ServeHTTP(w, r)
|
||||
},
|
||||
nil, nil, nil, nil,
|
||||
))
|
||||
a.mux.HandleFunc("/api/v1/services/status", methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
adminRole := models.RoleAdministrator
|
||||
a.requireRole(adminRole)(http.HandlerFunc(a.handleServiceStatus)).ServeHTTP(w, r)
|
||||
},
|
||||
nil, nil, nil, nil,
|
||||
))
|
||||
a.mux.HandleFunc("/api/v1/services/start", methodHandler(
|
||||
nil,
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
adminRole := models.RoleAdministrator
|
||||
a.requireRole(adminRole)(http.HandlerFunc(a.handleServiceStart)).ServeHTTP(w, r)
|
||||
},
|
||||
nil, nil, nil,
|
||||
))
|
||||
a.mux.HandleFunc("/api/v1/services/stop", methodHandler(
|
||||
nil,
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
adminRole := models.RoleAdministrator
|
||||
a.requireRole(adminRole)(http.HandlerFunc(a.handleServiceStop)).ServeHTTP(w, r)
|
||||
},
|
||||
nil, nil, nil,
|
||||
))
|
||||
a.mux.HandleFunc("/api/v1/services/restart", methodHandler(
|
||||
nil,
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
adminRole := models.RoleAdministrator
|
||||
a.requireRole(adminRole)(http.HandlerFunc(a.handleServiceRestart)).ServeHTTP(w, r)
|
||||
},
|
||||
nil, nil, nil,
|
||||
))
|
||||
a.mux.HandleFunc("/api/v1/services/reload", methodHandler(
|
||||
nil,
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
adminRole := models.RoleAdministrator
|
||||
a.requireRole(adminRole)(http.HandlerFunc(a.handleServiceReload)).ServeHTTP(w, r)
|
||||
},
|
||||
nil, nil, nil,
|
||||
))
|
||||
a.mux.HandleFunc("/api/v1/services/logs", methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
adminRole := models.RoleAdministrator
|
||||
a.requireRole(adminRole)(http.HandlerFunc(a.handleServiceLogs)).ServeHTTP(w, r)
|
||||
},
|
||||
nil, nil, nil, nil,
|
||||
))
|
||||
|
||||
// Audit Logs
|
||||
a.mux.HandleFunc("/api/v1/audit", a.handleListAuditLogs)
|
||||
}
|
||||
123
internal/httpapp/security_middleware.go
Normal file
123
internal/httpapp/security_middleware.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package httpapp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/errors"
|
||||
)
|
||||
|
||||
// securityHeadersMiddleware adds security headers to responses
|
||||
func (a *App) securityHeadersMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Security headers
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
|
||||
|
||||
// HSTS (only for HTTPS)
|
||||
if r.TLS != nil {
|
||||
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||
}
|
||||
|
||||
// Content Security Policy (CSP)
|
||||
// Allow Tailwind CDN, unpkg (for htmx), and jsdelivr for external resources
|
||||
// Note: Tailwind CDN needs connect-src to fetch config and make network requests
|
||||
csp := "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com https://unpkg.com https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com https://unpkg.com https://cdn.jsdelivr.net; img-src 'self' data:; font-src 'self' https://cdn.tailwindcss.com https://unpkg.com https://cdn.jsdelivr.net; connect-src 'self' https://cdn.tailwindcss.com https://unpkg.com https://cdn.jsdelivr.net;"
|
||||
w.Header().Set("Content-Security-Policy", csp)
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// corsMiddleware handles CORS requests
|
||||
func (a *App) corsMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
|
||||
// Allow specific origins or all (for development)
|
||||
allowedOrigins := []string{
|
||||
"http://localhost:8080",
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:8080",
|
||||
}
|
||||
|
||||
// Check if origin is allowed
|
||||
allowed := false
|
||||
for _, allowedOrigin := range allowedOrigins {
|
||||
if origin == allowedOrigin {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Allow requests from same origin
|
||||
if origin == "" || r.Header.Get("Referer") != "" {
|
||||
allowed = true
|
||||
}
|
||||
|
||||
if allowed && origin != "" {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Max-Age", "3600")
|
||||
}
|
||||
|
||||
// Handle preflight requests
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// requestSizeMiddleware limits request body size
|
||||
func (a *App) requestSizeMiddleware(maxSize int64) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Limit request body size
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxSize)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// validateContentTypeMiddleware validates Content-Type for POST/PUT/PATCH requests
|
||||
func (a *App) validateContentTypeMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Skip for GET, HEAD, OPTIONS, DELETE
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead ||
|
||||
r.Method == http.MethodOptions || r.Method == http.MethodDelete {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Skip for public endpoints
|
||||
if a.isPublicEndpoint(r.URL.Path, r.Method) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Check Content-Type for POST/PUT/PATCH
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
writeError(w, errors.ErrBadRequest("Content-Type header is required"))
|
||||
return
|
||||
}
|
||||
|
||||
// Allow JSON and form data
|
||||
if !strings.HasPrefix(contentType, "application/json") &&
|
||||
!strings.HasPrefix(contentType, "application/x-www-form-urlencoded") &&
|
||||
!strings.HasPrefix(contentType, "multipart/form-data") {
|
||||
writeError(w, errors.ErrBadRequest("Content-Type must be application/json"))
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
249
internal/httpapp/service_handlers.go
Normal file
249
internal/httpapp/service_handlers.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package httpapp
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ManagedService represents a service with its status
|
||||
type ManagedService struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Status string `json:"status"`
|
||||
Output string `json:"output,omitempty"`
|
||||
}
|
||||
|
||||
// getServiceName extracts service name from query parameter or defaults to atlas-api
|
||||
func getServiceName(r *http.Request) string {
|
||||
serviceName := r.URL.Query().Get("service")
|
||||
if serviceName == "" {
|
||||
return "atlas-api"
|
||||
}
|
||||
return serviceName
|
||||
}
|
||||
|
||||
// getAllServices returns list of all managed services
|
||||
func getAllServices() []ManagedService {
|
||||
services := []ManagedService{
|
||||
{Name: "atlas-api", DisplayName: "AtlasOS API"},
|
||||
{Name: "smbd", DisplayName: "SMB/CIFS (Samba)"},
|
||||
{Name: "nfs-server", DisplayName: "NFS Server"},
|
||||
{Name: "target", DisplayName: "iSCSI Target"},
|
||||
}
|
||||
return services
|
||||
}
|
||||
|
||||
// getServiceStatus returns the status of a specific service
|
||||
func getServiceStatus(serviceName string) (string, string, error) {
|
||||
cmd := exec.Command("systemctl", "status", serviceName, "--no-pager", "-l")
|
||||
output, err := cmd.CombinedOutput()
|
||||
outputStr := string(output)
|
||||
|
||||
if err != nil {
|
||||
// systemctl status returns non-zero exit code even when service is running
|
||||
// Check if output contains "active (running)" to determine actual status
|
||||
if strings.Contains(outputStr, "active (running)") {
|
||||
return "running", outputStr, nil
|
||||
}
|
||||
if strings.Contains(outputStr, "inactive (dead)") {
|
||||
return "stopped", outputStr, nil
|
||||
}
|
||||
if strings.Contains(outputStr, "failed") {
|
||||
return "failed", outputStr, nil
|
||||
}
|
||||
// Service might not exist
|
||||
if strings.Contains(outputStr, "could not be found") || strings.Contains(outputStr, "not found") {
|
||||
return "not-found", outputStr, nil
|
||||
}
|
||||
return "unknown", outputStr, err
|
||||
}
|
||||
|
||||
status := "unknown"
|
||||
if strings.Contains(outputStr, "active (running)") {
|
||||
status = "running"
|
||||
} else if strings.Contains(outputStr, "inactive (dead)") {
|
||||
status = "stopped"
|
||||
} else if strings.Contains(outputStr, "failed") {
|
||||
status = "failed"
|
||||
}
|
||||
|
||||
return status, outputStr, nil
|
||||
}
|
||||
|
||||
// handleListServices returns the status of all services
|
||||
func (a *App) handleListServices(w http.ResponseWriter, r *http.Request) {
|
||||
allServices := getAllServices()
|
||||
servicesStatus := make([]ManagedService, 0, len(allServices))
|
||||
|
||||
for _, svc := range allServices {
|
||||
status, output, err := getServiceStatus(svc.Name)
|
||||
if err != nil {
|
||||
log.Printf("error getting status for %s: %v", svc.Name, err)
|
||||
status = "error"
|
||||
}
|
||||
servicesStatus = append(servicesStatus, ManagedService{
|
||||
Name: svc.Name,
|
||||
DisplayName: svc.DisplayName,
|
||||
Status: status,
|
||||
Output: output,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"services": servicesStatus,
|
||||
})
|
||||
}
|
||||
|
||||
// handleServiceStatus returns the status of a specific service
|
||||
func (a *App) handleServiceStatus(w http.ResponseWriter, r *http.Request) {
|
||||
serviceName := getServiceName(r)
|
||||
status, output, err := getServiceStatus(serviceName)
|
||||
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "failed to get service status",
|
||||
"details": output,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"service": serviceName,
|
||||
"status": status,
|
||||
"output": output,
|
||||
})
|
||||
}
|
||||
|
||||
// handleServiceStart starts a service
|
||||
func (a *App) handleServiceStart(w http.ResponseWriter, r *http.Request) {
|
||||
serviceName := getServiceName(r)
|
||||
|
||||
var cmd *exec.Cmd
|
||||
// Special handling for SMB - use smbcontrol for reload, but systemctl for start/stop
|
||||
if serviceName == "smbd" {
|
||||
cmd = exec.Command("systemctl", "start", "smbd")
|
||||
} else {
|
||||
cmd = exec.Command("systemctl", "start", serviceName)
|
||||
}
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
log.Printf("service start error for %s: %v", serviceName, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||
"error": "failed to start service",
|
||||
"service": serviceName,
|
||||
"details": string(output),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "service started successfully",
|
||||
"service": serviceName,
|
||||
})
|
||||
}
|
||||
|
||||
// handleServiceStop stops a service
|
||||
func (a *App) handleServiceStop(w http.ResponseWriter, r *http.Request) {
|
||||
serviceName := getServiceName(r)
|
||||
cmd := exec.Command("systemctl", "stop", serviceName)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
log.Printf("service stop error for %s: %v", serviceName, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||
"error": "failed to stop service",
|
||||
"service": serviceName,
|
||||
"details": string(output),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "service stopped successfully",
|
||||
"service": serviceName,
|
||||
})
|
||||
}
|
||||
|
||||
// handleServiceRestart restarts a service
|
||||
func (a *App) handleServiceRestart(w http.ResponseWriter, r *http.Request) {
|
||||
serviceName := getServiceName(r)
|
||||
cmd := exec.Command("systemctl", "restart", serviceName)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
log.Printf("service restart error for %s: %v", serviceName, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||
"error": "failed to restart service",
|
||||
"service": serviceName,
|
||||
"details": string(output),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "service restarted successfully",
|
||||
"service": serviceName,
|
||||
})
|
||||
}
|
||||
|
||||
// handleServiceReload reloads a service
|
||||
func (a *App) handleServiceReload(w http.ResponseWriter, r *http.Request) {
|
||||
serviceName := getServiceName(r)
|
||||
|
||||
var cmd *exec.Cmd
|
||||
// Special handling for SMB - use smbcontrol for reload
|
||||
if serviceName == "smbd" {
|
||||
cmd = exec.Command("smbcontrol", "all", "reload-config")
|
||||
} else {
|
||||
cmd = exec.Command("systemctl", "reload", serviceName)
|
||||
}
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
log.Printf("service reload error for %s: %v", serviceName, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||
"error": "failed to reload service",
|
||||
"service": serviceName,
|
||||
"details": string(output),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "service reloaded successfully",
|
||||
"service": serviceName,
|
||||
})
|
||||
}
|
||||
|
||||
// handleServiceLogs returns the logs of a service
|
||||
func (a *App) handleServiceLogs(w http.ResponseWriter, r *http.Request) {
|
||||
serviceName := getServiceName(r)
|
||||
|
||||
// Get number of lines from query parameter (default: 50)
|
||||
linesStr := r.URL.Query().Get("lines")
|
||||
lines := "50"
|
||||
if linesStr != "" {
|
||||
if n, err := strconv.Atoi(linesStr); err == nil && n > 0 && n <= 1000 {
|
||||
lines = linesStr
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.Command("journalctl", "-u", serviceName, "-n", lines, "--no-pager")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
log.Printf("service logs error for %s: %v", serviceName, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||
"error": "failed to get service logs",
|
||||
"service": serviceName,
|
||||
"details": string(output),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"service": serviceName,
|
||||
"logs": string(output),
|
||||
})
|
||||
}
|
||||
78
internal/httpapp/user_ops.go
Normal file
78
internal/httpapp/user_ops.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package httpapp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/models"
|
||||
)
|
||||
|
||||
// handleUserOpsWithAuth routes user operations with auth
|
||||
func (a *App) handleUserOpsWithAuth(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/password") {
|
||||
// Password change endpoint (requires auth, can change own password)
|
||||
if r.Method == http.MethodPut {
|
||||
a.handleChangePassword(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Regular user operations (admin-only)
|
||||
methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
a.requireRole(models.RoleAdministrator)(http.HandlerFunc(a.handleGetUser)).ServeHTTP(w, r)
|
||||
},
|
||||
nil,
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
a.requireRole(models.RoleAdministrator)(http.HandlerFunc(a.handleUpdateUser)).ServeHTTP(w, r)
|
||||
},
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
a.requireRole(models.RoleAdministrator)(http.HandlerFunc(a.handleDeleteUser)).ServeHTTP(w, r)
|
||||
},
|
||||
nil,
|
||||
)(w, r)
|
||||
}
|
||||
|
||||
// handleChangePassword allows users to change their own password
|
||||
func (a *App) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := getUserFromContext(r)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
OldPassword string `json:"old_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.NewPassword == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "new password is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify old password
|
||||
_, err := a.userStore.Authenticate(user.Username, req.OldPassword)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid current password"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update password
|
||||
if err := a.userStore.UpdatePassword(user.ID, req.NewPassword); err != nil {
|
||||
log.Printf("update password error: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "password updated"})
|
||||
}
|
||||
58
internal/httpapp/validation_helpers.go
Normal file
58
internal/httpapp/validation_helpers.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package httpapp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// parseSizeString parses a human-readable size string to bytes
|
||||
func (a *App) parseSizeString(sizeStr string) (uint64, error) {
|
||||
sizeStr = strings.TrimSpace(strings.ToUpper(sizeStr))
|
||||
if sizeStr == "" {
|
||||
return 0, fmt.Errorf("size cannot be empty")
|
||||
}
|
||||
|
||||
// Extract number and unit
|
||||
var numStr string
|
||||
var unit string
|
||||
|
||||
for i, r := range sizeStr {
|
||||
if r >= '0' && r <= '9' {
|
||||
numStr += string(r)
|
||||
} else {
|
||||
unit = sizeStr[i:]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if numStr == "" {
|
||||
return 0, fmt.Errorf("invalid size format: no number found")
|
||||
}
|
||||
|
||||
num, err := strconv.ParseUint(numStr, 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid size number: %w", err)
|
||||
}
|
||||
|
||||
// Convert to bytes based on unit
|
||||
multiplier := uint64(1)
|
||||
switch unit {
|
||||
case "":
|
||||
multiplier = 1
|
||||
case "K", "KB":
|
||||
multiplier = 1024
|
||||
case "M", "MB":
|
||||
multiplier = 1024 * 1024
|
||||
case "G", "GB":
|
||||
multiplier = 1024 * 1024 * 1024
|
||||
case "T", "TB":
|
||||
multiplier = 1024 * 1024 * 1024 * 1024
|
||||
case "P", "PB":
|
||||
multiplier = 1024 * 1024 * 1024 * 1024 * 1024
|
||||
default:
|
||||
return 0, fmt.Errorf("invalid size unit: %s (allowed: K, M, G, T, P)", unit)
|
||||
}
|
||||
|
||||
return num * multiplier, nil
|
||||
}
|
||||
163
internal/job/manager.go
Normal file
163
internal/job/manager.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/models"
|
||||
)
|
||||
|
||||
// Manager handles job lifecycle and tracking
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
jobs map[string]*models.Job
|
||||
nextID int64
|
||||
}
|
||||
|
||||
// NewManager creates a new job manager
|
||||
func NewManager() *Manager {
|
||||
return &Manager{
|
||||
jobs: make(map[string]*models.Job),
|
||||
nextID: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// Create creates a new job
|
||||
func (m *Manager) Create(jobType string, metadata map[string]interface{}) *models.Job {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
id := fmt.Sprintf("job-%d", m.nextID)
|
||||
m.nextID++
|
||||
|
||||
job := &models.Job{
|
||||
ID: id,
|
||||
Type: jobType,
|
||||
Status: models.JobStatusPending,
|
||||
Progress: 0,
|
||||
Message: "Job created",
|
||||
Metadata: metadata,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
m.jobs[id] = job
|
||||
return job
|
||||
}
|
||||
|
||||
// Get returns a job by ID
|
||||
func (m *Manager) Get(id string) (*models.Job, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
job, exists := m.jobs[id]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("job %s not found", id)
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
|
||||
// List returns all jobs, optionally filtered by status
|
||||
func (m *Manager) List(status models.JobStatus) []models.Job {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
var jobs []models.Job
|
||||
for _, job := range m.jobs {
|
||||
if status == "" || job.Status == status {
|
||||
jobs = append(jobs, *job)
|
||||
}
|
||||
}
|
||||
return jobs
|
||||
}
|
||||
|
||||
// UpdateStatus updates job status
|
||||
func (m *Manager) UpdateStatus(id string, status models.JobStatus, message string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
job, exists := m.jobs[id]
|
||||
if !exists {
|
||||
return fmt.Errorf("job %s not found", id)
|
||||
}
|
||||
|
||||
job.Status = status
|
||||
job.Message = message
|
||||
|
||||
now := time.Now()
|
||||
switch status {
|
||||
case models.JobStatusRunning:
|
||||
if job.StartedAt == nil {
|
||||
job.StartedAt = &now
|
||||
}
|
||||
case models.JobStatusCompleted, models.JobStatusFailed, models.JobStatusCancelled:
|
||||
job.CompletedAt = &now
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateProgress updates job progress (0-100)
|
||||
func (m *Manager) UpdateProgress(id string, progress int, message string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
job, exists := m.jobs[id]
|
||||
if !exists {
|
||||
return fmt.Errorf("job %s not found", id)
|
||||
}
|
||||
|
||||
if progress < 0 {
|
||||
progress = 0
|
||||
}
|
||||
if progress > 100 {
|
||||
progress = 100
|
||||
}
|
||||
|
||||
job.Progress = progress
|
||||
if message != "" {
|
||||
job.Message = message
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetError sets job error and marks as failed
|
||||
func (m *Manager) SetError(id string, err error) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
job, exists := m.jobs[id]
|
||||
if !exists {
|
||||
return fmt.Errorf("job %s not found", id)
|
||||
}
|
||||
|
||||
job.Status = models.JobStatusFailed
|
||||
job.Error = err.Error()
|
||||
now := time.Now()
|
||||
job.CompletedAt = &now
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cancel cancels a job
|
||||
func (m *Manager) Cancel(id string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
job, exists := m.jobs[id]
|
||||
if !exists {
|
||||
return fmt.Errorf("job %s not found", id)
|
||||
}
|
||||
|
||||
if job.Status == models.JobStatusCompleted || job.Status == models.JobStatusFailed {
|
||||
return fmt.Errorf("cannot cancel job in status %s", job.Status)
|
||||
}
|
||||
|
||||
job.Status = models.JobStatusCancelled
|
||||
job.Message = "Job cancelled by user"
|
||||
now := time.Now()
|
||||
job.CompletedAt = &now
|
||||
|
||||
return nil
|
||||
}
|
||||
215
internal/logger/logger.go
Normal file
215
internal/logger/logger.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Level represents log level
|
||||
type Level int
|
||||
|
||||
const (
|
||||
LevelDebug Level = iota
|
||||
LevelInfo
|
||||
LevelWarn
|
||||
LevelError
|
||||
)
|
||||
|
||||
var levelNames = map[Level]string{
|
||||
LevelDebug: "DEBUG",
|
||||
LevelInfo: "INFO",
|
||||
LevelWarn: "WARN",
|
||||
LevelError: "ERROR",
|
||||
}
|
||||
|
||||
// Logger provides structured logging
|
||||
type Logger struct {
|
||||
mu sync.Mutex
|
||||
level Level
|
||||
output io.Writer
|
||||
jsonMode bool
|
||||
prefix string
|
||||
}
|
||||
|
||||
// LogEntry represents a structured log entry
|
||||
type LogEntry struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
Fields map[string]interface{} `json:"fields,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// New creates a new logger
|
||||
func New(level Level, output io.Writer, jsonMode bool) *Logger {
|
||||
if output == nil {
|
||||
output = os.Stdout
|
||||
}
|
||||
return &Logger{
|
||||
level: level,
|
||||
output: output,
|
||||
jsonMode: jsonMode,
|
||||
}
|
||||
}
|
||||
|
||||
// SetLevel sets the log level
|
||||
func (l *Logger) SetLevel(level Level) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.level = level
|
||||
}
|
||||
|
||||
// SetOutput sets the output writer
|
||||
func (l *Logger) SetOutput(w io.Writer) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.output = w
|
||||
}
|
||||
|
||||
// Debug logs a debug message
|
||||
func (l *Logger) Debug(msg string, fields ...map[string]interface{}) {
|
||||
l.log(LevelDebug, msg, nil, fields...)
|
||||
}
|
||||
|
||||
// Info logs an info message
|
||||
func (l *Logger) Info(msg string, fields ...map[string]interface{}) {
|
||||
l.log(LevelInfo, msg, nil, fields...)
|
||||
}
|
||||
|
||||
// Warn logs a warning message
|
||||
func (l *Logger) Warn(msg string, fields ...map[string]interface{}) {
|
||||
l.log(LevelWarn, msg, nil, fields...)
|
||||
}
|
||||
|
||||
// Error logs an error message
|
||||
func (l *Logger) Error(msg string, err error, fields ...map[string]interface{}) {
|
||||
l.log(LevelError, msg, err, fields...)
|
||||
}
|
||||
|
||||
// log writes a log entry
|
||||
func (l *Logger) log(level Level, msg string, err error, fields ...map[string]interface{}) {
|
||||
if level < l.level {
|
||||
return
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
entry := LogEntry{
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
Level: levelNames[level],
|
||||
Message: msg,
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
entry.Error = err.Error()
|
||||
}
|
||||
|
||||
// Merge fields
|
||||
if len(fields) > 0 {
|
||||
entry.Fields = make(map[string]interface{})
|
||||
for _, f := range fields {
|
||||
for k, v := range f {
|
||||
entry.Fields[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var output string
|
||||
if l.jsonMode {
|
||||
jsonData, jsonErr := json.Marshal(entry)
|
||||
if jsonErr != nil {
|
||||
// Fallback to text format if JSON fails
|
||||
output = fmt.Sprintf("%s [%s] %s", entry.Timestamp, entry.Level, msg)
|
||||
if err != nil {
|
||||
output += fmt.Sprintf(" error=%v", err)
|
||||
}
|
||||
} else {
|
||||
output = string(jsonData)
|
||||
}
|
||||
} else {
|
||||
// Text format
|
||||
output = fmt.Sprintf("%s [%s] %s", entry.Timestamp, entry.Level, msg)
|
||||
if err != nil {
|
||||
output += fmt.Sprintf(" error=%v", err)
|
||||
}
|
||||
if len(entry.Fields) > 0 {
|
||||
for k, v := range entry.Fields {
|
||||
output += fmt.Sprintf(" %s=%v", k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintln(l.output, output)
|
||||
}
|
||||
|
||||
// WithFields returns a logger with additional fields
|
||||
func (l *Logger) WithFields(fields map[string]interface{}) *Logger {
|
||||
return &Logger{
|
||||
level: l.level,
|
||||
output: l.output,
|
||||
jsonMode: l.jsonMode,
|
||||
prefix: l.prefix,
|
||||
}
|
||||
}
|
||||
|
||||
// ParseLevel parses a log level string
|
||||
func ParseLevel(s string) Level {
|
||||
switch s {
|
||||
case "DEBUG", "debug":
|
||||
return LevelDebug
|
||||
case "INFO", "info":
|
||||
return LevelInfo
|
||||
case "WARN", "warn", "WARNING", "warning":
|
||||
return LevelWarn
|
||||
case "ERROR", "error":
|
||||
return LevelError
|
||||
default:
|
||||
return LevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
// Default logger instance
|
||||
var defaultLogger *Logger
|
||||
|
||||
func init() {
|
||||
levelStr := os.Getenv("ATLAS_LOG_LEVEL")
|
||||
level := ParseLevel(levelStr)
|
||||
jsonMode := os.Getenv("ATLAS_LOG_FORMAT") == "json"
|
||||
|
||||
defaultLogger = New(level, os.Stdout, jsonMode)
|
||||
}
|
||||
|
||||
// Debug logs using default logger
|
||||
func Debug(msg string, fields ...map[string]interface{}) {
|
||||
defaultLogger.Debug(msg, fields...)
|
||||
}
|
||||
|
||||
// Info logs using default logger
|
||||
func Info(msg string, fields ...map[string]interface{}) {
|
||||
defaultLogger.Info(msg, fields...)
|
||||
}
|
||||
|
||||
// Warn logs using default logger
|
||||
func Warn(msg string, fields ...map[string]interface{}) {
|
||||
defaultLogger.Warn(msg, fields...)
|
||||
}
|
||||
|
||||
// Error logs using default logger
|
||||
func Error(msg string, err error, fields ...map[string]interface{}) {
|
||||
defaultLogger.Error(msg, err, fields...)
|
||||
}
|
||||
|
||||
// SetLevel sets the default logger level
|
||||
func SetLevel(level Level) {
|
||||
defaultLogger.SetLevel(level)
|
||||
}
|
||||
|
||||
// GetLogger returns the default logger
|
||||
func GetLogger() *Logger {
|
||||
return defaultLogger
|
||||
}
|
||||
130
internal/maintenance/service.go
Normal file
130
internal/maintenance/service.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package maintenance
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Mode represents the maintenance mode state
|
||||
type Mode struct {
|
||||
mu sync.RWMutex
|
||||
enabled bool
|
||||
enabledAt time.Time
|
||||
enabledBy string
|
||||
reason string
|
||||
allowedUsers []string // Users allowed to operate during maintenance
|
||||
lastBackupID string // ID of backup created before entering maintenance
|
||||
}
|
||||
|
||||
// Service manages maintenance mode
|
||||
type Service struct {
|
||||
mode *Mode
|
||||
}
|
||||
|
||||
// NewService creates a new maintenance service
|
||||
func NewService() *Service {
|
||||
return &Service{
|
||||
mode: &Mode{
|
||||
allowedUsers: []string{},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// IsEnabled returns whether maintenance mode is currently enabled
|
||||
func (s *Service) IsEnabled() bool {
|
||||
s.mode.mu.RLock()
|
||||
defer s.mode.mu.RUnlock()
|
||||
return s.mode.enabled
|
||||
}
|
||||
|
||||
// Enable enables maintenance mode
|
||||
func (s *Service) Enable(enabledBy, reason string, allowedUsers []string) error {
|
||||
s.mode.mu.Lock()
|
||||
defer s.mode.mu.Unlock()
|
||||
|
||||
if s.mode.enabled {
|
||||
return fmt.Errorf("maintenance mode is already enabled")
|
||||
}
|
||||
|
||||
s.mode.enabled = true
|
||||
s.mode.enabledAt = time.Now()
|
||||
s.mode.enabledBy = enabledBy
|
||||
s.mode.reason = reason
|
||||
if allowedUsers != nil {
|
||||
s.mode.allowedUsers = allowedUsers
|
||||
} else {
|
||||
s.mode.allowedUsers = []string{}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Disable disables maintenance mode
|
||||
func (s *Service) Disable(disabledBy string) error {
|
||||
s.mode.mu.Lock()
|
||||
defer s.mode.mu.Unlock()
|
||||
|
||||
if !s.mode.enabled {
|
||||
return fmt.Errorf("maintenance mode is not enabled")
|
||||
}
|
||||
|
||||
s.mode.enabled = false
|
||||
s.mode.enabledBy = ""
|
||||
s.mode.reason = ""
|
||||
s.mode.allowedUsers = []string{}
|
||||
s.mode.lastBackupID = ""
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetStatus returns the current maintenance mode status
|
||||
func (s *Service) GetStatus() Status {
|
||||
s.mode.mu.RLock()
|
||||
defer s.mode.mu.RUnlock()
|
||||
|
||||
return Status{
|
||||
Enabled: s.mode.enabled,
|
||||
EnabledAt: s.mode.enabledAt,
|
||||
EnabledBy: s.mode.enabledBy,
|
||||
Reason: s.mode.reason,
|
||||
AllowedUsers: s.mode.allowedUsers,
|
||||
LastBackupID: s.mode.lastBackupID,
|
||||
}
|
||||
}
|
||||
|
||||
// SetLastBackupID sets the backup ID created before entering maintenance
|
||||
func (s *Service) SetLastBackupID(backupID string) {
|
||||
s.mode.mu.Lock()
|
||||
defer s.mode.mu.Unlock()
|
||||
s.mode.lastBackupID = backupID
|
||||
}
|
||||
|
||||
// IsUserAllowed checks if a user is allowed to operate during maintenance
|
||||
func (s *Service) IsUserAllowed(userID string) bool {
|
||||
s.mode.mu.RLock()
|
||||
defer s.mode.mu.RUnlock()
|
||||
|
||||
if !s.mode.enabled {
|
||||
return true // No restrictions when not in maintenance
|
||||
}
|
||||
|
||||
// Check if user is in allowed list
|
||||
for _, allowed := range s.mode.allowedUsers {
|
||||
if allowed == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Status represents the maintenance mode status
|
||||
type Status struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
EnabledAt time.Time `json:"enabled_at,omitempty"`
|
||||
EnabledBy string `json:"enabled_by,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
AllowedUsers []string `json:"allowed_users,omitempty"`
|
||||
LastBackupID string `json:"last_backup_id,omitempty"`
|
||||
}
|
||||
217
internal/metrics/collector.go
Normal file
217
internal/metrics/collector.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/models"
|
||||
)
|
||||
|
||||
// Collector gathers system metrics
|
||||
type Collector struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
// ZFS metrics
|
||||
poolCount int
|
||||
datasetCount int
|
||||
zvolCount int
|
||||
snapshotCount int
|
||||
totalCapacity uint64
|
||||
totalAllocated uint64
|
||||
totalFree uint64
|
||||
|
||||
// Service metrics
|
||||
smbSharesCount int
|
||||
nfsExportsCount int
|
||||
iscsiTargetsCount int
|
||||
smbServiceStatus int // 1 = running, 0 = stopped
|
||||
nfsServiceStatus int
|
||||
iscsiServiceStatus int
|
||||
|
||||
// Job metrics
|
||||
jobsTotal int
|
||||
jobsRunning int
|
||||
jobsCompleted int
|
||||
jobsFailed int
|
||||
|
||||
// System metrics
|
||||
uptimeSeconds int64
|
||||
lastUpdate time.Time
|
||||
}
|
||||
|
||||
// NewCollector creates a new metrics collector
|
||||
func NewCollector() *Collector {
|
||||
return &Collector{
|
||||
lastUpdate: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateZFSMetrics updates ZFS-related metrics
|
||||
func (c *Collector) UpdateZFSMetrics(pools []models.Pool, datasets []models.Dataset, zvols []models.ZVOL, snapshots []models.Snapshot) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.poolCount = len(pools)
|
||||
c.datasetCount = len(datasets)
|
||||
c.zvolCount = len(zvols)
|
||||
c.snapshotCount = len(snapshots)
|
||||
|
||||
c.totalCapacity = 0
|
||||
c.totalAllocated = 0
|
||||
c.totalFree = 0
|
||||
|
||||
for _, pool := range pools {
|
||||
c.totalCapacity += pool.Size
|
||||
c.totalAllocated += pool.Allocated
|
||||
c.totalFree += pool.Free
|
||||
}
|
||||
|
||||
c.lastUpdate = time.Now()
|
||||
}
|
||||
|
||||
// UpdateServiceMetrics updates storage service metrics
|
||||
func (c *Collector) UpdateServiceMetrics(smbShares, nfsExports, iscsiTargets int, smbStatus, nfsStatus, iscsiStatus bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.smbSharesCount = smbShares
|
||||
c.nfsExportsCount = nfsExports
|
||||
c.iscsiTargetsCount = iscsiTargets
|
||||
|
||||
if smbStatus {
|
||||
c.smbServiceStatus = 1
|
||||
} else {
|
||||
c.smbServiceStatus = 0
|
||||
}
|
||||
|
||||
if nfsStatus {
|
||||
c.nfsServiceStatus = 1
|
||||
} else {
|
||||
c.nfsServiceStatus = 0
|
||||
}
|
||||
|
||||
if iscsiStatus {
|
||||
c.iscsiServiceStatus = 1
|
||||
} else {
|
||||
c.iscsiServiceStatus = 0
|
||||
}
|
||||
|
||||
c.lastUpdate = time.Now()
|
||||
}
|
||||
|
||||
// UpdateJobMetrics updates job-related metrics
|
||||
func (c *Collector) UpdateJobMetrics(total, running, completed, failed int) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.jobsTotal = total
|
||||
c.jobsRunning = running
|
||||
c.jobsCompleted = completed
|
||||
c.jobsFailed = failed
|
||||
|
||||
c.lastUpdate = time.Now()
|
||||
}
|
||||
|
||||
// SetUptime sets the system uptime
|
||||
func (c *Collector) SetUptime(seconds int64) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.uptimeSeconds = seconds
|
||||
}
|
||||
|
||||
// Collect returns metrics in Prometheus format
|
||||
func (c *Collector) Collect() string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
var output string
|
||||
|
||||
// Build info
|
||||
output += "# HELP atlas_build_info Build information\n"
|
||||
output += "# TYPE atlas_build_info gauge\n"
|
||||
output += `atlas_build_info{version="v0.1.0-dev"} 1` + "\n\n"
|
||||
|
||||
// System uptime
|
||||
output += "# HELP atlas_uptime_seconds System uptime in seconds\n"
|
||||
output += "# TYPE atlas_uptime_seconds gauge\n"
|
||||
output += fmt.Sprintf("atlas_uptime_seconds %d\n\n", c.uptimeSeconds)
|
||||
|
||||
// ZFS metrics
|
||||
output += "# HELP atlas_zfs_pools_total Total number of ZFS pools\n"
|
||||
output += "# TYPE atlas_zfs_pools_total gauge\n"
|
||||
output += fmt.Sprintf("atlas_zfs_pools_total %d\n\n", c.poolCount)
|
||||
|
||||
output += "# HELP atlas_zfs_datasets_total Total number of ZFS datasets\n"
|
||||
output += "# TYPE atlas_zfs_datasets_total gauge\n"
|
||||
output += fmt.Sprintf("atlas_zfs_datasets_total %d\n\n", c.datasetCount)
|
||||
|
||||
output += "# HELP atlas_zfs_zvols_total Total number of ZFS ZVOLs\n"
|
||||
output += "# TYPE atlas_zfs_zvols_total gauge\n"
|
||||
output += fmt.Sprintf("atlas_zfs_zvols_total %d\n\n", c.zvolCount)
|
||||
|
||||
output += "# HELP atlas_zfs_snapshots_total Total number of ZFS snapshots\n"
|
||||
output += "# TYPE atlas_zfs_snapshots_total gauge\n"
|
||||
output += fmt.Sprintf("atlas_zfs_snapshots_total %d\n\n", c.snapshotCount)
|
||||
|
||||
output += "# HELP atlas_zfs_capacity_bytes Total ZFS pool capacity in bytes\n"
|
||||
output += "# TYPE atlas_zfs_capacity_bytes gauge\n"
|
||||
output += fmt.Sprintf("atlas_zfs_capacity_bytes %d\n\n", c.totalCapacity)
|
||||
|
||||
output += "# HELP atlas_zfs_allocated_bytes Total ZFS pool allocated space in bytes\n"
|
||||
output += "# TYPE atlas_zfs_allocated_bytes gauge\n"
|
||||
output += fmt.Sprintf("atlas_zfs_allocated_bytes %d\n\n", c.totalAllocated)
|
||||
|
||||
output += "# HELP atlas_zfs_free_bytes Total ZFS pool free space in bytes\n"
|
||||
output += "# TYPE atlas_zfs_free_bytes gauge\n"
|
||||
output += fmt.Sprintf("atlas_zfs_free_bytes %d\n\n", c.totalFree)
|
||||
|
||||
// Service metrics
|
||||
output += "# HELP atlas_smb_shares_total Total number of SMB shares\n"
|
||||
output += "# TYPE atlas_smb_shares_total gauge\n"
|
||||
output += fmt.Sprintf("atlas_smb_shares_total %d\n\n", c.smbSharesCount)
|
||||
|
||||
output += "# HELP atlas_nfs_exports_total Total number of NFS exports\n"
|
||||
output += "# TYPE atlas_nfs_exports_total gauge\n"
|
||||
output += fmt.Sprintf("atlas_nfs_exports_total %d\n\n", c.nfsExportsCount)
|
||||
|
||||
output += "# HELP atlas_iscsi_targets_total Total number of iSCSI targets\n"
|
||||
output += "# TYPE atlas_iscsi_targets_total gauge\n"
|
||||
output += fmt.Sprintf("atlas_iscsi_targets_total %d\n\n", c.iscsiTargetsCount)
|
||||
|
||||
output += "# HELP atlas_smb_service_status SMB service status (1=running, 0=stopped)\n"
|
||||
output += "# TYPE atlas_smb_service_status gauge\n"
|
||||
output += fmt.Sprintf("atlas_smb_service_status %d\n\n", c.smbServiceStatus)
|
||||
|
||||
output += "# HELP atlas_nfs_service_status NFS service status (1=running, 0=stopped)\n"
|
||||
output += "# TYPE atlas_nfs_service_status gauge\n"
|
||||
output += fmt.Sprintf("atlas_nfs_service_status %d\n\n", c.nfsServiceStatus)
|
||||
|
||||
output += "# HELP atlas_iscsi_service_status iSCSI service status (1=running, 0=stopped)\n"
|
||||
output += "# TYPE atlas_iscsi_service_status gauge\n"
|
||||
output += fmt.Sprintf("atlas_iscsi_service_status %d\n\n", c.iscsiServiceStatus)
|
||||
|
||||
// Job metrics
|
||||
output += "# HELP atlas_jobs_total Total number of jobs\n"
|
||||
output += "# TYPE atlas_jobs_total gauge\n"
|
||||
output += fmt.Sprintf("atlas_jobs_total %d\n\n", c.jobsTotal)
|
||||
|
||||
output += "# HELP atlas_jobs_running Number of running jobs\n"
|
||||
output += "# TYPE atlas_jobs_running gauge\n"
|
||||
output += fmt.Sprintf("atlas_jobs_running %d\n\n", c.jobsRunning)
|
||||
|
||||
output += "# HELP atlas_jobs_completed_total Total number of completed jobs\n"
|
||||
output += "# TYPE atlas_jobs_completed_total counter\n"
|
||||
output += fmt.Sprintf("atlas_jobs_completed_total %d\n\n", c.jobsCompleted)
|
||||
|
||||
output += "# HELP atlas_jobs_failed_total Total number of failed jobs\n"
|
||||
output += "# TYPE atlas_jobs_failed_total counter\n"
|
||||
output += fmt.Sprintf("atlas_jobs_failed_total %d\n\n", c.jobsFailed)
|
||||
|
||||
// API status
|
||||
output += "# HELP atlas_up Whether the atlas-api process is up\n"
|
||||
output += "# TYPE atlas_up gauge\n"
|
||||
output += "atlas_up 1\n"
|
||||
|
||||
return output
|
||||
}
|
||||
36
internal/models/auth.go
Normal file
36
internal/models/auth.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Role represents a user role
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleAdministrator Role = "administrator"
|
||||
RoleOperator Role = "operator"
|
||||
RoleViewer Role = "viewer"
|
||||
)
|
||||
|
||||
// User represents a system user
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Role Role `json:"role"`
|
||||
Active bool `json:"active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// AuditLog represents an audit log entry
|
||||
type AuditLog struct {
|
||||
ID string `json:"id"`
|
||||
Actor string `json:"actor"` // user ID or system
|
||||
Action string `json:"action"` // "pool.create", "share.delete", etc.
|
||||
Resource string `json:"resource"` // resource type and ID
|
||||
Result string `json:"result"` // "success", "failure"
|
||||
Message string `json:"message,omitempty"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
UserAgent string `json:"user_agent,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
28
internal/models/job.go
Normal file
28
internal/models/job.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// JobStatus represents the state of a job
|
||||
type JobStatus string
|
||||
|
||||
const (
|
||||
JobStatusPending JobStatus = "pending"
|
||||
JobStatusRunning JobStatus = "running"
|
||||
JobStatusCompleted JobStatus = "completed"
|
||||
JobStatusFailed JobStatus = "failed"
|
||||
JobStatusCancelled JobStatus = "cancelled"
|
||||
)
|
||||
|
||||
// Job represents a long-running asynchronous operation
|
||||
type Job struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // "pool_create", "snapshot_create", etc.
|
||||
Status JobStatus `json:"status"`
|
||||
Progress int `json:"progress"` // 0-100
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
}
|
||||
42
internal/models/storage.go
Normal file
42
internal/models/storage.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package models
|
||||
|
||||
// SMBShare represents an SMB/CIFS share
|
||||
type SMBShare struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"` // dataset mountpoint
|
||||
Dataset string `json:"dataset"` // ZFS dataset name
|
||||
Description string `json:"description"`
|
||||
ReadOnly bool `json:"read_only"`
|
||||
GuestOK bool `json:"guest_ok"`
|
||||
ValidUsers []string `json:"valid_users"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// NFSExport represents an NFS export
|
||||
type NFSExport struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"` // dataset mountpoint
|
||||
Dataset string `json:"dataset"` // ZFS dataset name
|
||||
Clients []string `json:"clients"` // allowed clients (CIDR or hostnames)
|
||||
ReadOnly bool `json:"read_only"`
|
||||
RootSquash bool `json:"root_squash"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// ISCSITarget represents an iSCSI target
|
||||
type ISCSITarget struct {
|
||||
ID string `json:"id"`
|
||||
IQN string `json:"iqn"` // iSCSI Qualified Name
|
||||
LUNs []LUN `json:"luns"`
|
||||
Initiators []string `json:"initiators"` // ACL list
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// LUN represents a Logical Unit Number backed by a ZVOL
|
||||
type LUN struct {
|
||||
ID int `json:"id"` // LUN number
|
||||
ZVOL string `json:"zvol"` // ZVOL name
|
||||
Size uint64 `json:"size"` // bytes
|
||||
Backend string `json:"backend"` // "zvol"
|
||||
}
|
||||
56
internal/models/zfs.go
Normal file
56
internal/models/zfs.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Pool represents a ZFS pool
|
||||
type Pool struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"` // ONLINE, DEGRADED, FAULTED, etc.
|
||||
Size uint64 `json:"size"` // bytes
|
||||
Allocated uint64 `json:"allocated"`
|
||||
Free uint64 `json:"free"`
|
||||
Health string `json:"health"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Dataset represents a ZFS filesystem
|
||||
type Dataset struct {
|
||||
Name string `json:"name"`
|
||||
Pool string `json:"pool"`
|
||||
Type string `json:"type"` // filesystem, volume
|
||||
Size uint64 `json:"size"`
|
||||
Used uint64 `json:"used"`
|
||||
Available uint64 `json:"available"`
|
||||
Mountpoint string `json:"mountpoint"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ZVOL represents a ZFS block device
|
||||
type ZVOL struct {
|
||||
Name string `json:"name"`
|
||||
Pool string `json:"pool"`
|
||||
Size uint64 `json:"size"` // bytes
|
||||
Used uint64 `json:"used"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Snapshot represents a ZFS snapshot
|
||||
type Snapshot struct {
|
||||
Name string `json:"name"`
|
||||
Dataset string `json:"dataset"`
|
||||
Size uint64 `json:"size"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// SnapshotPolicy defines automated snapshot rules
|
||||
type SnapshotPolicy struct {
|
||||
Dataset string `json:"dataset"`
|
||||
Frequent int `json:"frequent"` // keep N frequent snapshots
|
||||
Hourly int `json:"hourly"` // keep N hourly snapshots
|
||||
Daily int `json:"daily"` // keep N daily snapshots
|
||||
Weekly int `json:"weekly"` // keep N weekly snapshots
|
||||
Monthly int `json:"monthly"` // keep N monthly snapshots
|
||||
Yearly int `json:"yearly"` // keep N yearly snapshots
|
||||
Autosnap bool `json:"autosnap"` // enable/disable
|
||||
Autoprune bool `json:"autoprune"` // enable/disable
|
||||
}
|
||||
374
internal/services/iscsi.go
Normal file
374
internal/services/iscsi.go
Normal file
@@ -0,0 +1,374 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/models"
|
||||
)
|
||||
|
||||
// ISCSIService manages iSCSI target service integration
|
||||
type ISCSIService struct {
|
||||
mu sync.RWMutex
|
||||
targetcliPath string
|
||||
}
|
||||
|
||||
// NewISCSIService creates a new iSCSI service manager
|
||||
func NewISCSIService() *ISCSIService {
|
||||
// Try targetcli first, fallback to targetcli-fb
|
||||
targetcliPath := "targetcli"
|
||||
if _, err := exec.LookPath("targetcli"); err != nil {
|
||||
if _, err := exec.LookPath("targetcli-fb"); err == nil {
|
||||
targetcliPath = "targetcli-fb"
|
||||
}
|
||||
}
|
||||
|
||||
return &ISCSIService{
|
||||
targetcliPath: targetcliPath,
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyConfiguration applies iSCSI target configurations
|
||||
func (s *ISCSIService) ApplyConfiguration(targets []models.ISCSITarget) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// For each target, ensure it exists and is configured
|
||||
for _, target := range targets {
|
||||
if !target.Enabled {
|
||||
// Disable target if it exists
|
||||
if err := s.disableTarget(target.IQN); err != nil {
|
||||
// Log but continue
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Create or update target
|
||||
if err := s.createTarget(target); err != nil {
|
||||
return fmt.Errorf("create target %s: %w", target.IQN, err)
|
||||
}
|
||||
|
||||
// Configure ACLs
|
||||
if err := s.configureACLs(target); err != nil {
|
||||
return fmt.Errorf("configure ACLs for %s: %w", target.IQN, err)
|
||||
}
|
||||
|
||||
// Configure LUNs
|
||||
if err := s.configureLUNs(target); err != nil {
|
||||
return fmt.Errorf("configure LUNs for %s: %w", target.IQN, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createTarget creates an iSCSI target
|
||||
func (s *ISCSIService) createTarget(target models.ISCSITarget) error {
|
||||
// Use targetcli to create target
|
||||
// Format: targetcli /iscsi create <IQN>
|
||||
cmd := exec.Command(s.targetcliPath, "/iscsi", "create", target.IQN)
|
||||
if err := cmd.Run(); err != nil {
|
||||
// Target might already exist, which is OK
|
||||
// Check if it actually exists
|
||||
if !s.targetExists(target.IQN) {
|
||||
return fmt.Errorf("create target failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// configureACLs configures initiator ACLs for a target
|
||||
func (s *ISCSIService) configureACLs(target models.ISCSITarget) error {
|
||||
// Get current ACLs
|
||||
currentACLs, _ := s.getACLs(target.IQN)
|
||||
|
||||
// Remove ACLs not in desired list
|
||||
for _, acl := range currentACLs {
|
||||
if !contains(target.Initiators, acl) {
|
||||
cmd := exec.Command(s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1/acls", "delete", acl)
|
||||
cmd.Run() // Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
// Add new ACLs
|
||||
for _, initiator := range target.Initiators {
|
||||
if !contains(currentACLs, initiator) {
|
||||
cmd := exec.Command(s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1/acls", "create", initiator)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("create ACL %s: %w", initiator, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// configureLUNs configures LUNs for a target
|
||||
func (s *ISCSIService) configureLUNs(target models.ISCSITarget) error {
|
||||
// Get current LUNs
|
||||
currentLUNs, _ := s.getLUNs(target.IQN)
|
||||
|
||||
// Remove LUNs not in desired list
|
||||
for _, lun := range currentLUNs {
|
||||
if !s.hasLUN(target.LUNs, lun) {
|
||||
cmd := exec.Command(s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1/luns", "delete", fmt.Sprintf("lun/%d", lun))
|
||||
cmd.Run() // Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
// Add/update LUNs
|
||||
for _, lun := range target.LUNs {
|
||||
// Create LUN mapping
|
||||
// Format: targetcli /iscsi/<IQN>/tpg1/luns create /backstores/zvol/<zvol>
|
||||
zvolPath := "/backstores/zvol/" + lun.ZVOL
|
||||
|
||||
// First ensure the zvol backend exists
|
||||
cmd := exec.Command(s.targetcliPath, "/backstores/zvol", "create", lun.ZVOL, lun.ZVOL)
|
||||
cmd.Run() // Ignore if already exists
|
||||
|
||||
// Create LUN
|
||||
cmd = exec.Command(s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1/luns", "create", zvolPath)
|
||||
if err := cmd.Run(); err != nil {
|
||||
// LUN might already exist
|
||||
if !s.hasLUNID(currentLUNs, lun.ID) {
|
||||
return fmt.Errorf("create LUN %d: %w", lun.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func (s *ISCSIService) targetExists(iqn string) bool {
|
||||
cmd := exec.Command(s.targetcliPath, "/iscsi", "ls")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(string(output), iqn)
|
||||
}
|
||||
|
||||
func (s *ISCSIService) getACLs(iqn string) ([]string, error) {
|
||||
cmd := exec.Command(s.targetcliPath, "/iscsi/"+iqn+"/tpg1/acls", "ls")
|
||||
_, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Parse output to extract ACL names
|
||||
// This is simplified - real implementation would parse targetcli output
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
func (s *ISCSIService) getLUNs(iqn string) ([]int, error) {
|
||||
cmd := exec.Command(s.targetcliPath, "/iscsi/"+iqn+"/tpg1/luns", "ls")
|
||||
_, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Parse output to extract LUN IDs
|
||||
// This is simplified - real implementation would parse targetcli output
|
||||
return []int{}, nil
|
||||
}
|
||||
|
||||
func (s *ISCSIService) hasLUN(luns []models.LUN, id int) bool {
|
||||
for _, lun := range luns {
|
||||
if lun.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *ISCSIService) hasLUNID(luns []int, id int) bool {
|
||||
for _, lunID := range luns {
|
||||
if lunID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *ISCSIService) disableTarget(iqn string) error {
|
||||
cmd := exec.Command(s.targetcliPath, "/iscsi/"+iqn+"/tpg1", "set", "attribute", "enable=0")
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// GetStatus returns the status of iSCSI target service
|
||||
func (s *ISCSIService) GetStatus() (bool, error) {
|
||||
// Check if targetd is running
|
||||
cmd := exec.Command("systemctl", "is-active", "target")
|
||||
if err := cmd.Run(); err == nil {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Fallback: check process
|
||||
cmd = exec.Command("pgrep", "-x", "targetd")
|
||||
if err := cmd.Run(); err == nil {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func contains(slice []string, item string) bool {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ConnectionInstructions represents iSCSI connection instructions
|
||||
type ConnectionInstructions struct {
|
||||
IQN string `json:"iqn"`
|
||||
Portal string `json:"portal"` // IP:port
|
||||
PortalIP string `json:"portal_ip"` // IP address
|
||||
PortalPort int `json:"portal_port"` // Port (default 3260)
|
||||
LUNs []LUNInfo `json:"luns"`
|
||||
Commands Commands `json:"commands"`
|
||||
}
|
||||
|
||||
// LUNInfo represents LUN information for connection
|
||||
type LUNInfo struct {
|
||||
ID int `json:"id"`
|
||||
ZVOL string `json:"zvol"`
|
||||
Size uint64 `json:"size"`
|
||||
}
|
||||
|
||||
// Commands contains OS-specific connection commands
|
||||
type Commands struct {
|
||||
Linux []string `json:"linux"`
|
||||
Windows []string `json:"windows"`
|
||||
MacOS []string `json:"macos"`
|
||||
}
|
||||
|
||||
// GetConnectionInstructions generates connection instructions for an iSCSI target
|
||||
func (s *ISCSIService) GetConnectionInstructions(target models.ISCSITarget, portalIP string, portalPort int) *ConnectionInstructions {
|
||||
if portalPort == 0 {
|
||||
portalPort = 3260 // Default iSCSI port
|
||||
}
|
||||
|
||||
portal := fmt.Sprintf("%s:%d", portalIP, portalPort)
|
||||
|
||||
// Build LUN information
|
||||
luns := make([]LUNInfo, len(target.LUNs))
|
||||
for i, lun := range target.LUNs {
|
||||
luns[i] = LUNInfo{
|
||||
ID: lun.ID,
|
||||
ZVOL: lun.ZVOL,
|
||||
Size: lun.Size,
|
||||
}
|
||||
}
|
||||
|
||||
// Generate Linux commands
|
||||
linuxCmds := []string{
|
||||
fmt.Sprintf("# Discover target"),
|
||||
fmt.Sprintf("iscsiadm -m discovery -t sendtargets -p %s", portal),
|
||||
fmt.Sprintf(""),
|
||||
fmt.Sprintf("# Login to target"),
|
||||
fmt.Sprintf("iscsiadm -m node -T %s -p %s --login", target.IQN, portal),
|
||||
fmt.Sprintf(""),
|
||||
fmt.Sprintf("# Verify connection"),
|
||||
fmt.Sprintf("iscsiadm -m session"),
|
||||
fmt.Sprintf(""),
|
||||
fmt.Sprintf("# Logout (when done)"),
|
||||
fmt.Sprintf("iscsiadm -m node -T %s -p %s --logout", target.IQN, portal),
|
||||
}
|
||||
|
||||
// Generate Windows commands
|
||||
windowsCmds := []string{
|
||||
fmt.Sprintf("# Open PowerShell as Administrator"),
|
||||
fmt.Sprintf(""),
|
||||
fmt.Sprintf("# Add iSCSI target portal"),
|
||||
fmt.Sprintf("New-IscsiTargetPortal -TargetPortalAddress %s -TargetPortalPortNumber %d", portalIP, portalPort),
|
||||
fmt.Sprintf(""),
|
||||
fmt.Sprintf("# Connect to target"),
|
||||
fmt.Sprintf("Connect-IscsiTarget -NodeAddress %s", target.IQN),
|
||||
fmt.Sprintf(""),
|
||||
fmt.Sprintf("# Verify connection"),
|
||||
fmt.Sprintf("Get-IscsiSession"),
|
||||
fmt.Sprintf(""),
|
||||
fmt.Sprintf("# Disconnect (when done)"),
|
||||
fmt.Sprintf("Disconnect-IscsiTarget -NodeAddress %s", target.IQN),
|
||||
}
|
||||
|
||||
// Generate macOS commands
|
||||
macosCmds := []string{
|
||||
fmt.Sprintf("# macOS uses built-in iSCSI support"),
|
||||
fmt.Sprintf("# Use System Preferences > Network > iSCSI"),
|
||||
fmt.Sprintf(""),
|
||||
fmt.Sprintf("# Or use command line (if iscsiutil is available)"),
|
||||
fmt.Sprintf("iscsiutil -a -t %s -p %s", target.IQN, portal),
|
||||
fmt.Sprintf(""),
|
||||
fmt.Sprintf("# Portal: %s", portal),
|
||||
fmt.Sprintf("# Target IQN: %s", target.IQN),
|
||||
}
|
||||
|
||||
return &ConnectionInstructions{
|
||||
IQN: target.IQN,
|
||||
Portal: portal,
|
||||
PortalIP: portalIP,
|
||||
PortalPort: portalPort,
|
||||
LUNs: luns,
|
||||
Commands: Commands{
|
||||
Linux: linuxCmds,
|
||||
Windows: windowsCmds,
|
||||
MacOS: macosCmds,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetPortalIP attempts to detect the portal IP address
|
||||
func (s *ISCSIService) GetPortalIP() (string, error) {
|
||||
// Try to get IP from targetcli
|
||||
cmd := exec.Command(s.targetcliPath, "/iscsi", "ls")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// Fallback: try to get system IP
|
||||
return s.getSystemIP()
|
||||
}
|
||||
|
||||
// Parse output to find portal IP
|
||||
// This is a simplified version - real implementation would parse targetcli output properly
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "ipv4") || strings.Contains(line, "ipv6") {
|
||||
// Extract IP from line
|
||||
parts := strings.Fields(line)
|
||||
for _, part := range parts {
|
||||
// Check if it looks like an IP address
|
||||
if strings.Contains(part, ".") || strings.Contains(part, ":") {
|
||||
// Remove port if present
|
||||
if idx := strings.Index(part, ":"); idx > 0 {
|
||||
return part[:idx], nil
|
||||
}
|
||||
return part, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to system IP
|
||||
return s.getSystemIP()
|
||||
}
|
||||
|
||||
// getSystemIP gets a system IP address (simplified)
|
||||
func (s *ISCSIService) getSystemIP() (string, error) {
|
||||
// Try to get IP from hostname -I (Linux)
|
||||
cmd := exec.Command("hostname", "-I")
|
||||
output, err := cmd.Output()
|
||||
if err == nil {
|
||||
ips := strings.Fields(string(output))
|
||||
if len(ips) > 0 {
|
||||
return ips[0], nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: return localhost
|
||||
return "127.0.0.1", nil
|
||||
}
|
||||
148
internal/services/nfs.go
Normal file
148
internal/services/nfs.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/models"
|
||||
)
|
||||
|
||||
// NFSService manages NFS service integration
|
||||
type NFSService struct {
|
||||
mu sync.RWMutex
|
||||
exportsPath string
|
||||
}
|
||||
|
||||
// NewNFSService creates a new NFS service manager
|
||||
func NewNFSService() *NFSService {
|
||||
return &NFSService{
|
||||
exportsPath: "/etc/exports",
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyConfiguration generates and applies NFS exports configuration
|
||||
func (s *NFSService) ApplyConfiguration(exports []models.NFSExport) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
config, err := s.generateExports(exports)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate exports: %w", err)
|
||||
}
|
||||
|
||||
// Write configuration to a temporary file first
|
||||
tmpPath := s.exportsPath + ".atlas.tmp"
|
||||
if err := os.WriteFile(tmpPath, []byte(config), 0644); err != nil {
|
||||
return fmt.Errorf("write exports: %w", err)
|
||||
}
|
||||
|
||||
// Backup existing exports
|
||||
backupPath := s.exportsPath + ".backup"
|
||||
if _, err := os.Stat(s.exportsPath); err == nil {
|
||||
if err := exec.Command("cp", s.exportsPath, backupPath).Run(); err != nil {
|
||||
// Non-fatal, log but continue
|
||||
}
|
||||
}
|
||||
|
||||
// Atomically replace exports file
|
||||
if err := os.Rename(tmpPath, s.exportsPath); err != nil {
|
||||
return fmt.Errorf("replace exports: %w", err)
|
||||
}
|
||||
|
||||
// Reload NFS exports with error recovery
|
||||
reloadErr := s.reloadExports()
|
||||
if reloadErr != nil {
|
||||
// Try to restore backup on failure
|
||||
if _, err2 := os.Stat(backupPath); err2 == nil {
|
||||
if restoreErr := os.Rename(backupPath, s.exportsPath); restoreErr != nil {
|
||||
return fmt.Errorf("reload failed and backup restore failed: reload=%v, restore=%v", reloadErr, restoreErr)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("reload exports: %w", reloadErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateExports generates /etc/exports format from NFS exports
|
||||
func (s *NFSService) generateExports(exports []models.NFSExport) (string, error) {
|
||||
var b strings.Builder
|
||||
|
||||
for _, export := range exports {
|
||||
if !export.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
// Build export options
|
||||
var options []string
|
||||
if export.ReadOnly {
|
||||
options = append(options, "ro")
|
||||
} else {
|
||||
options = append(options, "rw")
|
||||
}
|
||||
|
||||
if export.RootSquash {
|
||||
options = append(options, "root_squash")
|
||||
} else {
|
||||
options = append(options, "no_root_squash")
|
||||
}
|
||||
|
||||
options = append(options, "sync", "subtree_check")
|
||||
|
||||
// Format: path client1(options) client2(options)
|
||||
optStr := "(" + strings.Join(options, ",") + ")"
|
||||
|
||||
if len(export.Clients) == 0 {
|
||||
// Default to all clients if none specified
|
||||
b.WriteString(fmt.Sprintf("%s *%s\n", export.Path, optStr))
|
||||
} else {
|
||||
for _, client := range export.Clients {
|
||||
b.WriteString(fmt.Sprintf("%s %s%s\n", export.Path, client, optStr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// reloadExports reloads NFS exports
|
||||
func (s *NFSService) reloadExports() error {
|
||||
// Use exportfs -ra to reload all exports
|
||||
cmd := exec.Command("exportfs", "-ra")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("exportfs failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateConfiguration validates NFS exports syntax
|
||||
func (s *NFSService) ValidateConfiguration(exports string) error {
|
||||
// Use exportfs -v to validate (dry-run)
|
||||
cmd := exec.Command("exportfs", "-v")
|
||||
cmd.Stdin = strings.NewReader(exports)
|
||||
|
||||
// Note: exportfs doesn't have a direct validation mode
|
||||
// We'll rely on the reload to catch errors
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetStatus returns the status of NFS service
|
||||
func (s *NFSService) GetStatus() (bool, error) {
|
||||
// Check if nfs-server is running
|
||||
cmd := exec.Command("systemctl", "is-active", "nfs-server")
|
||||
if err := cmd.Run(); err == nil {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Fallback: check process
|
||||
cmd = exec.Command("pgrep", "-x", "nfsd")
|
||||
if err := cmd.Run(); err == nil {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
170
internal/services/smb.go
Normal file
170
internal/services/smb.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/models"
|
||||
)
|
||||
|
||||
// SMBService manages Samba service integration
|
||||
type SMBService struct {
|
||||
mu sync.RWMutex
|
||||
configPath string
|
||||
smbConfPath string
|
||||
smbctlPath string
|
||||
}
|
||||
|
||||
// NewSMBService creates a new SMB service manager
|
||||
func NewSMBService() *SMBService {
|
||||
return &SMBService{
|
||||
configPath: "/etc/samba/smb.conf",
|
||||
smbctlPath: "smbcontrol",
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyConfiguration generates and applies SMB configuration
|
||||
func (s *SMBService) ApplyConfiguration(shares []models.SMBShare) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
config, err := s.generateConfig(shares)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate config: %w", err)
|
||||
}
|
||||
|
||||
// Write configuration to a temporary file first
|
||||
tmpPath := s.configPath + ".atlas.tmp"
|
||||
if err := os.WriteFile(tmpPath, []byte(config), 0644); err != nil {
|
||||
return fmt.Errorf("write config: %w", err)
|
||||
}
|
||||
|
||||
// Backup existing config
|
||||
backupPath := s.configPath + ".backup"
|
||||
if _, err := os.Stat(s.configPath); err == nil {
|
||||
if err := exec.Command("cp", s.configPath, backupPath).Run(); err != nil {
|
||||
// Non-fatal, log but continue
|
||||
}
|
||||
}
|
||||
|
||||
// Atomically replace config
|
||||
if err := os.Rename(tmpPath, s.configPath); err != nil {
|
||||
return fmt.Errorf("replace config: %w", err)
|
||||
}
|
||||
|
||||
// Reload Samba service with retry
|
||||
reloadErr := s.reloadService()
|
||||
if reloadErr != nil {
|
||||
// Try to restore backup on failure
|
||||
if _, err2 := os.Stat(backupPath); err2 == nil {
|
||||
if restoreErr := os.Rename(backupPath, s.configPath); restoreErr != nil {
|
||||
return fmt.Errorf("reload failed and backup restore failed: reload=%v, restore=%v", reloadErr, restoreErr)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("reload service: %w", reloadErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateConfig generates Samba configuration from shares
|
||||
func (s *SMBService) generateConfig(shares []models.SMBShare) (string, error) {
|
||||
var b strings.Builder
|
||||
|
||||
// Global section
|
||||
b.WriteString("[global]\n")
|
||||
b.WriteString(" workgroup = WORKGROUP\n")
|
||||
b.WriteString(" server string = AtlasOS Storage Server\n")
|
||||
b.WriteString(" security = user\n")
|
||||
b.WriteString(" map to guest = Bad User\n")
|
||||
b.WriteString(" dns proxy = no\n")
|
||||
b.WriteString("\n")
|
||||
|
||||
// Share sections
|
||||
for _, share := range shares {
|
||||
if !share.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
b.WriteString(fmt.Sprintf("[%s]\n", share.Name))
|
||||
b.WriteString(fmt.Sprintf(" path = %s\n", share.Path))
|
||||
if share.Description != "" {
|
||||
b.WriteString(fmt.Sprintf(" comment = %s\n", share.Description))
|
||||
}
|
||||
if share.ReadOnly {
|
||||
b.WriteString(" read only = yes\n")
|
||||
} else {
|
||||
b.WriteString(" read only = no\n")
|
||||
b.WriteString(" writable = yes\n")
|
||||
}
|
||||
if share.GuestOK {
|
||||
b.WriteString(" guest ok = yes\n")
|
||||
b.WriteString(" public = yes\n")
|
||||
} else {
|
||||
b.WriteString(" guest ok = no\n")
|
||||
}
|
||||
if len(share.ValidUsers) > 0 {
|
||||
b.WriteString(fmt.Sprintf(" valid users = %s\n", strings.Join(share.ValidUsers, ", ")))
|
||||
}
|
||||
b.WriteString(" browseable = yes\n")
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// reloadService reloads Samba configuration
|
||||
func (s *SMBService) reloadService() error {
|
||||
// Try smbcontrol first (doesn't require root for reload)
|
||||
cmd := exec.Command(s.smbctlPath, "all", "reload-config")
|
||||
if err := cmd.Run(); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fallback to systemctl if available
|
||||
cmd = exec.Command("systemctl", "reload", "smbd")
|
||||
if err := cmd.Run(); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try service command
|
||||
cmd = exec.Command("service", "smbd", "reload")
|
||||
if err := cmd.Run(); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("unable to reload Samba service")
|
||||
}
|
||||
|
||||
// ValidateConfiguration validates SMB configuration syntax
|
||||
func (s *SMBService) ValidateConfiguration(config string) error {
|
||||
// Use testparm to validate configuration
|
||||
cmd := exec.Command("testparm", "-s")
|
||||
cmd.Stdin = strings.NewReader(config)
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("configuration validation failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetStatus returns the status of Samba service
|
||||
func (s *SMBService) GetStatus() (bool, error) {
|
||||
// Check if smbd is running
|
||||
cmd := exec.Command("systemctl", "is-active", "smbd")
|
||||
if err := cmd.Run(); err == nil {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Fallback: check process
|
||||
cmd = exec.Command("pgrep", "-x", "smbd")
|
||||
if err := cmd.Run(); err == nil {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
76
internal/snapshot/policy.go
Normal file
76
internal/snapshot/policy.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package snapshot
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/models"
|
||||
)
|
||||
|
||||
// PolicyStore manages snapshot policies
|
||||
type PolicyStore struct {
|
||||
mu sync.RWMutex
|
||||
policies map[string]*models.SnapshotPolicy
|
||||
}
|
||||
|
||||
// NewPolicyStore creates a new policy store
|
||||
func NewPolicyStore() *PolicyStore {
|
||||
return &PolicyStore{
|
||||
policies: make(map[string]*models.SnapshotPolicy),
|
||||
}
|
||||
}
|
||||
|
||||
// List returns all snapshot policies
|
||||
func (s *PolicyStore) List() []models.SnapshotPolicy {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
policies := make([]models.SnapshotPolicy, 0, len(s.policies))
|
||||
for _, p := range s.policies {
|
||||
policies = append(policies, *p)
|
||||
}
|
||||
return policies
|
||||
}
|
||||
|
||||
// Get returns a policy for a dataset
|
||||
func (s *PolicyStore) Get(dataset string) (*models.SnapshotPolicy, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
policy, exists := s.policies[dataset]
|
||||
if !exists {
|
||||
return nil, nil // Return nil if not found (not an error)
|
||||
}
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
// Set creates or updates a policy
|
||||
func (s *PolicyStore) Set(policy *models.SnapshotPolicy) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.policies[policy.Dataset] = policy
|
||||
}
|
||||
|
||||
// Delete removes a policy
|
||||
func (s *PolicyStore) Delete(dataset string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
delete(s.policies, dataset)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListForDataset returns all policies (for future filtering by dataset prefix)
|
||||
func (s *PolicyStore) ListForDataset(datasetPrefix string) []models.SnapshotPolicy {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var policies []models.SnapshotPolicy
|
||||
for _, p := range s.policies {
|
||||
if datasetPrefix == "" || p.Dataset == datasetPrefix ||
|
||||
(len(p.Dataset) > len(datasetPrefix) && p.Dataset[:len(datasetPrefix)] == datasetPrefix) {
|
||||
policies = append(policies, *p)
|
||||
}
|
||||
}
|
||||
return policies
|
||||
}
|
||||
261
internal/snapshot/scheduler.go
Normal file
261
internal/snapshot/scheduler.go
Normal file
@@ -0,0 +1,261 @@
|
||||
package snapshot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/job"
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/models"
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/zfs"
|
||||
)
|
||||
|
||||
// Scheduler manages automatic snapshot creation and pruning
|
||||
type Scheduler struct {
|
||||
policyStore *PolicyStore
|
||||
zfsService *zfs.Service
|
||||
jobManager *job.Manager
|
||||
stopChan chan struct{}
|
||||
ticker *time.Ticker
|
||||
}
|
||||
|
||||
// NewScheduler creates a new snapshot scheduler
|
||||
func NewScheduler(policyStore *PolicyStore, zfsService *zfs.Service, jobManager *job.Manager) *Scheduler {
|
||||
return &Scheduler{
|
||||
policyStore: policyStore,
|
||||
zfsService: zfsService,
|
||||
jobManager: jobManager,
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the scheduler with the given interval
|
||||
func (s *Scheduler) Start(interval time.Duration) {
|
||||
s.ticker = time.NewTicker(interval)
|
||||
log.Printf("[scheduler] started with interval %v", interval)
|
||||
|
||||
go s.run()
|
||||
}
|
||||
|
||||
// Stop stops the scheduler
|
||||
func (s *Scheduler) Stop() {
|
||||
if s.ticker != nil {
|
||||
s.ticker.Stop()
|
||||
}
|
||||
close(s.stopChan)
|
||||
log.Printf("[scheduler] stopped")
|
||||
}
|
||||
|
||||
// run executes the scheduler loop
|
||||
func (s *Scheduler) run() {
|
||||
// Run immediately on start
|
||||
s.execute()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.ticker.C:
|
||||
s.execute()
|
||||
case <-s.stopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// execute checks policies and creates/prunes snapshots
|
||||
func (s *Scheduler) execute() {
|
||||
policies := s.policyStore.List()
|
||||
log.Printf("[scheduler] checking %d snapshot policies", len(policies))
|
||||
|
||||
for _, policy := range policies {
|
||||
if !policy.Autosnap {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if we need to create a snapshot based on schedule
|
||||
s.checkAndCreateSnapshot(policy)
|
||||
|
||||
// Prune old snapshots if enabled
|
||||
if policy.Autoprune {
|
||||
s.pruneSnapshots(policy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkAndCreateSnapshot checks if a snapshot should be created
|
||||
func (s *Scheduler) checkAndCreateSnapshot(policy models.SnapshotPolicy) {
|
||||
now := time.Now()
|
||||
snapshots, err := s.zfsService.ListSnapshots(policy.Dataset)
|
||||
if err != nil {
|
||||
log.Printf("[scheduler] error listing snapshots for %s: %v", policy.Dataset, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we need frequent snapshots (every 15 minutes)
|
||||
if policy.Frequent > 0 {
|
||||
if s.shouldCreateSnapshot(snapshots, 15*time.Minute, policy.Frequent) {
|
||||
s.createSnapshot(policy.Dataset, "frequent", now)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we need hourly snapshots
|
||||
if policy.Hourly > 0 {
|
||||
if s.shouldCreateSnapshot(snapshots, time.Hour, policy.Hourly) {
|
||||
s.createSnapshot(policy.Dataset, "hourly", now)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we need daily snapshots
|
||||
if policy.Daily > 0 {
|
||||
if s.shouldCreateSnapshot(snapshots, 24*time.Hour, policy.Daily) {
|
||||
s.createSnapshot(policy.Dataset, "daily", now)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we need weekly snapshots
|
||||
if policy.Weekly > 0 {
|
||||
if s.shouldCreateSnapshot(snapshots, 7*24*time.Hour, policy.Weekly) {
|
||||
s.createSnapshot(policy.Dataset, "weekly", now)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we need monthly snapshots
|
||||
if policy.Monthly > 0 {
|
||||
if s.shouldCreateSnapshot(snapshots, 30*24*time.Hour, policy.Monthly) {
|
||||
s.createSnapshot(policy.Dataset, "monthly", now)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we need yearly snapshots
|
||||
if policy.Yearly > 0 {
|
||||
if s.shouldCreateSnapshot(snapshots, 365*24*time.Hour, policy.Yearly) {
|
||||
s.createSnapshot(policy.Dataset, "yearly", now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// shouldCreateSnapshot checks if a new snapshot should be created
|
||||
func (s *Scheduler) shouldCreateSnapshot(snapshots []models.Snapshot, interval time.Duration, keepCount int) bool {
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-interval)
|
||||
|
||||
// Count snapshots in the interval
|
||||
count := 0
|
||||
for _, snap := range snapshots {
|
||||
if snap.CreatedAt.After(cutoff) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
// If we have fewer than keepCount, we should create one
|
||||
return count < keepCount
|
||||
}
|
||||
|
||||
// createSnapshot creates a snapshot with a timestamped name
|
||||
func (s *Scheduler) createSnapshot(dataset, prefix string, t time.Time) {
|
||||
timestamp := t.Format("20060102-150405")
|
||||
name := fmt.Sprintf("%s-%s", prefix, timestamp)
|
||||
|
||||
job := s.jobManager.Create("snapshot_create", map[string]interface{}{
|
||||
"dataset": dataset,
|
||||
"name": name,
|
||||
"type": prefix,
|
||||
})
|
||||
|
||||
s.jobManager.UpdateStatus(job.ID, models.JobStatusRunning, fmt.Sprintf("Creating snapshot %s@%s", dataset, name))
|
||||
|
||||
if err := s.zfsService.CreateSnapshot(dataset, name, false); err != nil {
|
||||
log.Printf("[scheduler] error creating snapshot %s@%s: %v", dataset, name, err)
|
||||
s.jobManager.SetError(job.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
s.jobManager.UpdateProgress(job.ID, 100, "Snapshot created successfully")
|
||||
s.jobManager.UpdateStatus(job.ID, models.JobStatusCompleted, "Snapshot created")
|
||||
log.Printf("[scheduler] created snapshot %s@%s", dataset, name)
|
||||
}
|
||||
|
||||
// pruneSnapshots removes old snapshots based on retention policy
|
||||
func (s *Scheduler) pruneSnapshots(policy models.SnapshotPolicy) {
|
||||
snapshots, err := s.zfsService.ListSnapshots(policy.Dataset)
|
||||
if err != nil {
|
||||
log.Printf("[scheduler] error listing snapshots for pruning %s: %v", policy.Dataset, err)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
pruned := 0
|
||||
|
||||
// Group snapshots by type
|
||||
frequent := []models.Snapshot{}
|
||||
hourly := []models.Snapshot{}
|
||||
daily := []models.Snapshot{}
|
||||
weekly := []models.Snapshot{}
|
||||
monthly := []models.Snapshot{}
|
||||
yearly := []models.Snapshot{}
|
||||
|
||||
for _, snap := range snapshots {
|
||||
// Parse snapshot name to determine type
|
||||
parts := strings.Split(snap.Name, "@")
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
snapName := parts[1]
|
||||
|
||||
if strings.HasPrefix(snapName, "frequent-") {
|
||||
frequent = append(frequent, snap)
|
||||
} else if strings.HasPrefix(snapName, "hourly-") {
|
||||
hourly = append(hourly, snap)
|
||||
} else if strings.HasPrefix(snapName, "daily-") {
|
||||
daily = append(daily, snap)
|
||||
} else if strings.HasPrefix(snapName, "weekly-") {
|
||||
weekly = append(weekly, snap)
|
||||
} else if strings.HasPrefix(snapName, "monthly-") {
|
||||
monthly = append(monthly, snap)
|
||||
} else if strings.HasPrefix(snapName, "yearly-") {
|
||||
yearly = append(yearly, snap)
|
||||
}
|
||||
}
|
||||
|
||||
// Prune each type
|
||||
pruned += s.pruneByType(frequent, policy.Frequent, 15*time.Minute, now, policy.Dataset)
|
||||
pruned += s.pruneByType(hourly, policy.Hourly, time.Hour, now, policy.Dataset)
|
||||
pruned += s.pruneByType(daily, policy.Daily, 24*time.Hour, now, policy.Dataset)
|
||||
pruned += s.pruneByType(weekly, policy.Weekly, 7*24*time.Hour, now, policy.Dataset)
|
||||
pruned += s.pruneByType(monthly, policy.Monthly, 30*24*time.Hour, now, policy.Dataset)
|
||||
pruned += s.pruneByType(yearly, policy.Yearly, 365*24*time.Hour, now, policy.Dataset)
|
||||
|
||||
if pruned > 0 {
|
||||
log.Printf("[scheduler] pruned %d snapshots for %s", pruned, policy.Dataset)
|
||||
}
|
||||
}
|
||||
|
||||
// pruneByType prunes snapshots of a specific type
|
||||
func (s *Scheduler) pruneByType(snapshots []models.Snapshot, keepCount int, interval time.Duration, now time.Time, dataset string) int {
|
||||
if keepCount == 0 || len(snapshots) <= keepCount {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Sort by creation time (newest first)
|
||||
sort.Slice(snapshots, func(i, j int) bool {
|
||||
return snapshots[i].CreatedAt.After(snapshots[j].CreatedAt)
|
||||
})
|
||||
|
||||
// Keep the newest keepCount snapshots, delete the rest
|
||||
toDelete := snapshots[keepCount:]
|
||||
|
||||
pruned := 0
|
||||
for _, snap := range toDelete {
|
||||
// Only delete if it's older than the interval
|
||||
if now.Sub(snap.CreatedAt) > interval {
|
||||
if err := s.zfsService.DestroySnapshot(snap.Name, false); err != nil {
|
||||
log.Printf("[scheduler] error pruning snapshot %s: %v", snap.Name, err)
|
||||
continue
|
||||
}
|
||||
pruned++
|
||||
}
|
||||
}
|
||||
|
||||
return pruned
|
||||
}
|
||||
182
internal/storage/iscsi.go
Normal file
182
internal/storage/iscsi.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/models"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrISCSITargetNotFound = errors.New("iSCSI target not found")
|
||||
ErrISCSITargetExists = errors.New("iSCSI target already exists")
|
||||
ErrLUNNotFound = errors.New("LUN not found")
|
||||
ErrLUNExists = errors.New("LUN already exists")
|
||||
)
|
||||
|
||||
// ISCSIStore manages iSCSI targets and LUNs
|
||||
type ISCSIStore struct {
|
||||
mu sync.RWMutex
|
||||
targets map[string]*models.ISCSITarget
|
||||
nextID int
|
||||
}
|
||||
|
||||
// NewISCSIStore creates a new iSCSI store
|
||||
func NewISCSIStore() *ISCSIStore {
|
||||
return &ISCSIStore{
|
||||
targets: make(map[string]*models.ISCSITarget),
|
||||
nextID: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// List returns all iSCSI targets
|
||||
func (s *ISCSIStore) List() []models.ISCSITarget {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
targets := make([]models.ISCSITarget, 0, len(s.targets))
|
||||
for _, target := range s.targets {
|
||||
targets = append(targets, *target)
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
// Get returns a target by ID
|
||||
func (s *ISCSIStore) Get(id string) (*models.ISCSITarget, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
target, ok := s.targets[id]
|
||||
if !ok {
|
||||
return nil, ErrISCSITargetNotFound
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// GetByIQN returns a target by IQN
|
||||
func (s *ISCSIStore) GetByIQN(iqn string) (*models.ISCSITarget, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
for _, target := range s.targets {
|
||||
if target.IQN == iqn {
|
||||
return target, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrISCSITargetNotFound
|
||||
}
|
||||
|
||||
// Create creates a new iSCSI target
|
||||
func (s *ISCSIStore) Create(iqn string, initiators []string) (*models.ISCSITarget, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Check if IQN already exists
|
||||
for _, target := range s.targets {
|
||||
if target.IQN == iqn {
|
||||
return nil, ErrISCSITargetExists
|
||||
}
|
||||
}
|
||||
|
||||
id := fmt.Sprintf("iscsi-%d", s.nextID)
|
||||
s.nextID++
|
||||
|
||||
target := &models.ISCSITarget{
|
||||
ID: id,
|
||||
IQN: iqn,
|
||||
LUNs: []models.LUN{},
|
||||
Initiators: initiators,
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
s.targets[id] = target
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// Update updates an existing target
|
||||
func (s *ISCSIStore) Update(id string, initiators []string, enabled bool) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
target, ok := s.targets[id]
|
||||
if !ok {
|
||||
return ErrISCSITargetNotFound
|
||||
}
|
||||
|
||||
target.Enabled = enabled
|
||||
if initiators != nil {
|
||||
target.Initiators = initiators
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a target
|
||||
func (s *ISCSIStore) Delete(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, ok := s.targets[id]; !ok {
|
||||
return ErrISCSITargetNotFound
|
||||
}
|
||||
|
||||
delete(s.targets, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddLUN adds a LUN to a target
|
||||
func (s *ISCSIStore) AddLUN(targetID string, zvol string, size uint64) (*models.LUN, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
target, ok := s.targets[targetID]
|
||||
if !ok {
|
||||
return nil, ErrISCSITargetNotFound
|
||||
}
|
||||
|
||||
// Check if ZVOL already mapped
|
||||
for _, lun := range target.LUNs {
|
||||
if lun.ZVOL == zvol {
|
||||
return nil, ErrLUNExists
|
||||
}
|
||||
}
|
||||
|
||||
// Find next available LUN ID
|
||||
lunID := 0
|
||||
for _, lun := range target.LUNs {
|
||||
if lun.ID >= lunID {
|
||||
lunID = lun.ID + 1
|
||||
}
|
||||
}
|
||||
|
||||
lun := models.LUN{
|
||||
ID: lunID,
|
||||
ZVOL: zvol,
|
||||
Size: size,
|
||||
Backend: "zvol",
|
||||
}
|
||||
|
||||
target.LUNs = append(target.LUNs, lun)
|
||||
return &lun, nil
|
||||
}
|
||||
|
||||
// RemoveLUN removes a LUN from a target
|
||||
func (s *ISCSIStore) RemoveLUN(targetID string, lunID int) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
target, ok := s.targets[targetID]
|
||||
if !ok {
|
||||
return ErrISCSITargetNotFound
|
||||
}
|
||||
|
||||
for i, lun := range target.LUNs {
|
||||
if lun.ID == lunID {
|
||||
target.LUNs = append(target.LUNs[:i], target.LUNs[i+1:]...)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return ErrLUNNotFound
|
||||
}
|
||||
128
internal/storage/nfs.go
Normal file
128
internal/storage/nfs.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/models"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNFSExportNotFound = errors.New("NFS export not found")
|
||||
ErrNFSExportExists = errors.New("NFS export already exists")
|
||||
)
|
||||
|
||||
// NFSStore manages NFS exports
|
||||
type NFSStore struct {
|
||||
mu sync.RWMutex
|
||||
exports map[string]*models.NFSExport
|
||||
nextID int
|
||||
}
|
||||
|
||||
// NewNFSStore creates a new NFS export store
|
||||
func NewNFSStore() *NFSStore {
|
||||
return &NFSStore{
|
||||
exports: make(map[string]*models.NFSExport),
|
||||
nextID: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// List returns all NFS exports
|
||||
func (s *NFSStore) List() []models.NFSExport {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
exports := make([]models.NFSExport, 0, len(s.exports))
|
||||
for _, export := range s.exports {
|
||||
exports = append(exports, *export)
|
||||
}
|
||||
return exports
|
||||
}
|
||||
|
||||
// Get returns an export by ID
|
||||
func (s *NFSStore) Get(id string) (*models.NFSExport, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
export, ok := s.exports[id]
|
||||
if !ok {
|
||||
return nil, ErrNFSExportNotFound
|
||||
}
|
||||
return export, nil
|
||||
}
|
||||
|
||||
// GetByPath returns an export by path
|
||||
func (s *NFSStore) GetByPath(path string) (*models.NFSExport, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
for _, export := range s.exports {
|
||||
if export.Path == path {
|
||||
return export, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrNFSExportNotFound
|
||||
}
|
||||
|
||||
// Create creates a new NFS export
|
||||
func (s *NFSStore) Create(path, dataset string, clients []string, readOnly, rootSquash bool) (*models.NFSExport, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Check if path already exists
|
||||
for _, export := range s.exports {
|
||||
if export.Path == path {
|
||||
return nil, ErrNFSExportExists
|
||||
}
|
||||
}
|
||||
|
||||
id := fmt.Sprintf("nfs-%d", s.nextID)
|
||||
s.nextID++
|
||||
|
||||
export := &models.NFSExport{
|
||||
ID: id,
|
||||
Path: path,
|
||||
Dataset: dataset,
|
||||
Clients: clients,
|
||||
ReadOnly: readOnly,
|
||||
RootSquash: rootSquash,
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
s.exports[id] = export
|
||||
return export, nil
|
||||
}
|
||||
|
||||
// Update updates an existing export
|
||||
func (s *NFSStore) Update(id string, clients []string, readOnly, rootSquash, enabled bool) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
export, ok := s.exports[id]
|
||||
if !ok {
|
||||
return ErrNFSExportNotFound
|
||||
}
|
||||
|
||||
export.ReadOnly = readOnly
|
||||
export.RootSquash = rootSquash
|
||||
export.Enabled = enabled
|
||||
if clients != nil {
|
||||
export.Clients = clients
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes an export
|
||||
func (s *NFSStore) Delete(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, ok := s.exports[id]; !ok {
|
||||
return ErrNFSExportNotFound
|
||||
}
|
||||
|
||||
delete(s.exports, id)
|
||||
return nil
|
||||
}
|
||||
131
internal/storage/smb.go
Normal file
131
internal/storage/smb.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/models"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrSMBShareNotFound = errors.New("SMB share not found")
|
||||
ErrSMBShareExists = errors.New("SMB share already exists")
|
||||
)
|
||||
|
||||
// SMBStore manages SMB shares
|
||||
type SMBStore struct {
|
||||
mu sync.RWMutex
|
||||
shares map[string]*models.SMBShare
|
||||
nextID int
|
||||
}
|
||||
|
||||
// NewSMBStore creates a new SMB share store
|
||||
func NewSMBStore() *SMBStore {
|
||||
return &SMBStore{
|
||||
shares: make(map[string]*models.SMBShare),
|
||||
nextID: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// List returns all SMB shares
|
||||
func (s *SMBStore) List() []models.SMBShare {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
shares := make([]models.SMBShare, 0, len(s.shares))
|
||||
for _, share := range s.shares {
|
||||
shares = append(shares, *share)
|
||||
}
|
||||
return shares
|
||||
}
|
||||
|
||||
// Get returns a share by ID
|
||||
func (s *SMBStore) Get(id string) (*models.SMBShare, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
share, ok := s.shares[id]
|
||||
if !ok {
|
||||
return nil, ErrSMBShareNotFound
|
||||
}
|
||||
return share, nil
|
||||
}
|
||||
|
||||
// GetByName returns a share by name
|
||||
func (s *SMBStore) GetByName(name string) (*models.SMBShare, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
for _, share := range s.shares {
|
||||
if share.Name == name {
|
||||
return share, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrSMBShareNotFound
|
||||
}
|
||||
|
||||
// Create creates a new SMB share
|
||||
func (s *SMBStore) Create(name, path, dataset, description string, readOnly, guestOK bool, validUsers []string) (*models.SMBShare, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Check if name already exists
|
||||
for _, share := range s.shares {
|
||||
if share.Name == name {
|
||||
return nil, ErrSMBShareExists
|
||||
}
|
||||
}
|
||||
|
||||
id := fmt.Sprintf("smb-%d", s.nextID)
|
||||
s.nextID++
|
||||
|
||||
share := &models.SMBShare{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Path: path,
|
||||
Dataset: dataset,
|
||||
Description: description,
|
||||
ReadOnly: readOnly,
|
||||
GuestOK: guestOK,
|
||||
ValidUsers: validUsers,
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
s.shares[id] = share
|
||||
return share, nil
|
||||
}
|
||||
|
||||
// Update updates an existing share
|
||||
func (s *SMBStore) Update(id, description string, readOnly, guestOK, enabled bool, validUsers []string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
share, ok := s.shares[id]
|
||||
if !ok {
|
||||
return ErrSMBShareNotFound
|
||||
}
|
||||
|
||||
share.Description = description
|
||||
share.ReadOnly = readOnly
|
||||
share.GuestOK = guestOK
|
||||
share.Enabled = enabled
|
||||
if validUsers != nil {
|
||||
share.ValidUsers = validUsers
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a share
|
||||
func (s *SMBStore) Delete(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, ok := s.shares[id]; !ok {
|
||||
return ErrSMBShareNotFound
|
||||
}
|
||||
|
||||
delete(s.shares, id)
|
||||
return nil
|
||||
}
|
||||
157
internal/testing/helpers.go
Normal file
157
internal/testing/helpers.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package testing
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRequest represents a test HTTP request
|
||||
type TestRequest struct {
|
||||
Method string
|
||||
Path string
|
||||
Body interface{}
|
||||
Headers map[string]string
|
||||
}
|
||||
|
||||
// TestResponse represents a test HTTP response
|
||||
type TestResponse struct {
|
||||
StatusCode int
|
||||
Body map[string]interface{}
|
||||
Headers http.Header
|
||||
}
|
||||
|
||||
// MakeRequest creates and executes an HTTP request for testing
|
||||
func MakeRequest(t *testing.T, handler http.Handler, req TestRequest) *httptest.ResponseRecorder {
|
||||
var bodyBytes []byte
|
||||
if req.Body != nil {
|
||||
var err error
|
||||
bodyBytes, err = json.Marshal(req.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request body: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest(req.Method, req.Path, bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
t.Fatalf("create request: %v", err)
|
||||
}
|
||||
|
||||
// Set headers
|
||||
if req.Headers != nil {
|
||||
for k, v := range req.Headers {
|
||||
httpReq.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
// Set Content-Type if body is present
|
||||
if bodyBytes != nil && httpReq.Header.Get("Content-Type") == "" {
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, httpReq)
|
||||
|
||||
return recorder
|
||||
}
|
||||
|
||||
// AssertStatusCode asserts the response status code
|
||||
func AssertStatusCode(t *testing.T, recorder *httptest.ResponseRecorder, expected int) {
|
||||
if recorder.Code != expected {
|
||||
t.Errorf("expected status %d, got %d", expected, recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertJSONResponse asserts the response is valid JSON and matches expected structure
|
||||
func AssertJSONResponse(t *testing.T, recorder *httptest.ResponseRecorder) map[string]interface{} {
|
||||
var response map[string]interface{}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("unmarshal JSON response: %v\nBody: %s", err, recorder.Body.String())
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
// AssertHeader asserts a header value
|
||||
func AssertHeader(t *testing.T, recorder *httptest.ResponseRecorder, key, expected string) {
|
||||
actual := recorder.Header().Get(key)
|
||||
if actual != expected {
|
||||
t.Errorf("expected header %s=%s, got %s", key, expected, actual)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertErrorResponse asserts the response is an error response
|
||||
func AssertErrorResponse(t *testing.T, recorder *httptest.ResponseRecorder, expectedCode string) {
|
||||
response := AssertJSONResponse(t, recorder)
|
||||
|
||||
if code, ok := response["code"].(string); !ok || code != expectedCode {
|
||||
t.Errorf("expected error code %s, got %v", expectedCode, response["code"])
|
||||
}
|
||||
}
|
||||
|
||||
// AssertSuccessResponse asserts the response is a success response
|
||||
func AssertSuccessResponse(t *testing.T, recorder *httptest.ResponseRecorder) map[string]interface{} {
|
||||
AssertStatusCode(t, recorder, http.StatusOK)
|
||||
return AssertJSONResponse(t, recorder)
|
||||
}
|
||||
|
||||
// CreateTestUser creates a test user for authentication tests
|
||||
func CreateTestUser() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"username": "testuser",
|
||||
"password": "TestPass123",
|
||||
"email": "test@example.com",
|
||||
"role": "viewer",
|
||||
}
|
||||
}
|
||||
|
||||
// CreateTestToken creates a mock JWT token for testing
|
||||
func CreateTestToken(userID, role string) string {
|
||||
// In a real test, you'd use the actual auth service
|
||||
// This is a placeholder for test token generation
|
||||
return "test-token-" + userID
|
||||
}
|
||||
|
||||
// MockZFSClient provides a mock ZFS client for testing
|
||||
type MockZFSClient struct {
|
||||
Pools []map[string]interface{}
|
||||
Datasets []map[string]interface{}
|
||||
ZVOLs []map[string]interface{}
|
||||
Snapshots []map[string]interface{}
|
||||
Error error
|
||||
}
|
||||
|
||||
// NewMockZFSClient creates a new mock ZFS client
|
||||
func NewMockZFSClient() *MockZFSClient {
|
||||
return &MockZFSClient{
|
||||
Pools: []map[string]interface{}{},
|
||||
Datasets: []map[string]interface{}{},
|
||||
ZVOLs: []map[string]interface{}{},
|
||||
Snapshots: []map[string]interface{}{},
|
||||
}
|
||||
}
|
||||
|
||||
// SetError sets an error to return
|
||||
func (m *MockZFSClient) SetError(err error) {
|
||||
m.Error = err
|
||||
}
|
||||
|
||||
// AddPool adds a mock pool
|
||||
func (m *MockZFSClient) AddPool(pool map[string]interface{}) {
|
||||
m.Pools = append(m.Pools, pool)
|
||||
}
|
||||
|
||||
// AddDataset adds a mock dataset
|
||||
func (m *MockZFSClient) AddDataset(dataset map[string]interface{}) {
|
||||
m.Datasets = append(m.Datasets, dataset)
|
||||
}
|
||||
|
||||
// Reset clears all mock data
|
||||
func (m *MockZFSClient) Reset() {
|
||||
m.Pools = []map[string]interface{}{}
|
||||
m.Datasets = []map[string]interface{}{}
|
||||
m.ZVOLs = []map[string]interface{}{}
|
||||
m.Snapshots = []map[string]interface{}{}
|
||||
m.Error = nil
|
||||
}
|
||||
104
internal/tls/config.go
Normal file
104
internal/tls/config.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package tls
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Note: This package is named "tls" but provides configuration for crypto/tls
|
||||
|
||||
// Config holds TLS configuration
|
||||
type Config struct {
|
||||
CertFile string
|
||||
KeyFile string
|
||||
MinVersion uint16
|
||||
MaxVersion uint16
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// LoadConfig loads TLS configuration from environment variables
|
||||
func LoadConfig() *Config {
|
||||
cfg := &Config{
|
||||
CertFile: os.Getenv("ATLAS_TLS_CERT"),
|
||||
KeyFile: os.Getenv("ATLAS_TLS_KEY"),
|
||||
MinVersion: tls.VersionTLS12,
|
||||
MaxVersion: tls.VersionTLS13,
|
||||
Enabled: false,
|
||||
}
|
||||
|
||||
// Enable TLS if certificate and key are provided
|
||||
if cfg.CertFile != "" && cfg.KeyFile != "" {
|
||||
cfg.Enabled = true
|
||||
}
|
||||
|
||||
// Check if TLS is explicitly enabled
|
||||
if os.Getenv("ATLAS_TLS_ENABLED") == "true" {
|
||||
cfg.Enabled = true
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// BuildTLSConfig builds a crypto/tls.Config from the configuration
|
||||
func (c *Config) BuildTLSConfig() (*tls.Config, error) {
|
||||
if !c.Enabled {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Verify certificate and key files exist
|
||||
if _, err := os.Stat(c.CertFile); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("TLS certificate file not found: %s", c.CertFile)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(c.KeyFile); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("TLS key file not found: %s", c.KeyFile)
|
||||
}
|
||||
|
||||
// Load certificate
|
||||
cert, err := tls.LoadX509KeyPair(c.CertFile, c.KeyFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load TLS certificate: %w", err)
|
||||
}
|
||||
|
||||
config := &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
MinVersion: c.MinVersion,
|
||||
MaxVersion: c.MaxVersion,
|
||||
// Security best practices
|
||||
PreferServerCipherSuites: true,
|
||||
CurvePreferences: []tls.CurveID{
|
||||
tls.CurveP256,
|
||||
tls.CurveP384,
|
||||
tls.CurveP521,
|
||||
tls.X25519,
|
||||
},
|
||||
CipherSuites: []uint16{
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
|
||||
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
||||
},
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// Validate validates the TLS configuration
|
||||
func (c *Config) Validate() error {
|
||||
if !c.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
if c.CertFile == "" {
|
||||
return fmt.Errorf("TLS certificate file is required when TLS is enabled")
|
||||
}
|
||||
|
||||
if c.KeyFile == "" {
|
||||
return fmt.Errorf("TLS key file is required when TLS is enabled")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
1618
internal/tui/app.go
Normal file
1618
internal/tui/app.go
Normal file
File diff suppressed because it is too large
Load Diff
207
internal/tui/client.go
Normal file
207
internal/tui/client.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// APIClient provides a client for interacting with the Atlas API
|
||||
type APIClient struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
token string
|
||||
}
|
||||
|
||||
// NewAPIClient creates a new API client
|
||||
func NewAPIClient(baseURL string) *APIClient {
|
||||
return &APIClient{
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SetToken sets the authentication token
|
||||
func (c *APIClient) SetToken(token string) {
|
||||
c.token = token
|
||||
}
|
||||
|
||||
// Login authenticates with the API and returns user info
|
||||
func (c *APIClient) Login(username, password string) (string, map[string]interface{}, error) {
|
||||
reqBody := map[string]string{
|
||||
"username": username,
|
||||
"password": password,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", c.baseURL+"/api/v1/auth/login", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", nil, fmt.Errorf("login failed: %s", string(body))
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
token, ok := result["token"].(string)
|
||||
if !ok {
|
||||
return "", nil, fmt.Errorf("no token in response")
|
||||
}
|
||||
|
||||
c.SetToken(token)
|
||||
|
||||
// Extract user info if available
|
||||
var user map[string]interface{}
|
||||
if u, ok := result["user"].(map[string]interface{}); ok {
|
||||
user = u
|
||||
}
|
||||
|
||||
return token, user, nil
|
||||
}
|
||||
|
||||
// Get performs a GET request
|
||||
func (c *APIClient) Get(path string) ([]byte, error) {
|
||||
req, err := http.NewRequest("GET", c.baseURL+path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// Post performs a POST request
|
||||
func (c *APIClient) Post(path string, data interface{}) ([]byte, error) {
|
||||
body, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", c.baseURL+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
return respBody, nil
|
||||
}
|
||||
|
||||
// Delete performs a DELETE request
|
||||
func (c *APIClient) Delete(path string) error {
|
||||
req, err := http.NewRequest("DELETE", c.baseURL+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Put performs a PUT request
|
||||
func (c *APIClient) Put(path string, data interface{}) ([]byte, error) {
|
||||
body, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("PUT", c.baseURL+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
return respBody, nil
|
||||
}
|
||||
279
internal/validation/validator.go
Normal file
279
internal/validation/validator.go
Normal file
@@ -0,0 +1,279 @@
|
||||
package validation
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var (
|
||||
// Valid pool/dataset name pattern (ZFS naming rules)
|
||||
// Note: Forward slash (/) is allowed for dataset paths (e.g., "tank/data")
|
||||
zfsNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_\-\.:/]*$`)
|
||||
|
||||
// Valid username pattern
|
||||
usernamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_\-\.]{2,31}$`)
|
||||
|
||||
// Valid share name pattern (SMB naming rules)
|
||||
shareNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_\-\.]{0,79}$`)
|
||||
|
||||
// IQN pattern (simplified - iqn.yyyy-mm.reversed.domain:identifier)
|
||||
iqnPattern = regexp.MustCompile(`^iqn\.\d{4}-\d{2}\.[a-zA-Z0-9][a-zA-Z0-9\-\.]*:[a-zA-Z0-9][a-zA-Z0-9\-_\.]*$`)
|
||||
|
||||
// Email pattern (basic)
|
||||
emailPattern = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
|
||||
|
||||
// CIDR pattern for NFS clients
|
||||
cidrPattern = regexp.MustCompile(`^(\d{1,3}\.){3}\d{1,3}(/\d{1,2})?$`)
|
||||
)
|
||||
|
||||
// ValidationError represents a validation error
|
||||
type ValidationError struct {
|
||||
Field string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
if e.Field != "" {
|
||||
return fmt.Sprintf("validation error on field '%s': %s", e.Field, e.Message)
|
||||
}
|
||||
return fmt.Sprintf("validation error: %s", e.Message)
|
||||
}
|
||||
|
||||
// ValidateZFSName validates a ZFS pool or dataset name
|
||||
func ValidateZFSName(name string) error {
|
||||
if name == "" {
|
||||
return &ValidationError{Field: "name", Message: "name cannot be empty"}
|
||||
}
|
||||
|
||||
if len(name) > 256 {
|
||||
return &ValidationError{Field: "name", Message: "name too long (max 256 characters)"}
|
||||
}
|
||||
|
||||
if !zfsNamePattern.MatchString(name) {
|
||||
return &ValidationError{Field: "name", Message: "invalid characters (allowed: a-z, A-Z, 0-9, _, -, ., :, /)"}
|
||||
}
|
||||
|
||||
// ZFS names cannot start with certain characters
|
||||
if strings.HasPrefix(name, "-") || strings.HasPrefix(name, ".") {
|
||||
return &ValidationError{Field: "name", Message: "name cannot start with '-' or '.'"}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateUsername validates a username
|
||||
func ValidateUsername(username string) error {
|
||||
if username == "" {
|
||||
return &ValidationError{Field: "username", Message: "username cannot be empty"}
|
||||
}
|
||||
|
||||
if len(username) < 3 {
|
||||
return &ValidationError{Field: "username", Message: "username too short (min 3 characters)"}
|
||||
}
|
||||
|
||||
if len(username) > 32 {
|
||||
return &ValidationError{Field: "username", Message: "username too long (max 32 characters)"}
|
||||
}
|
||||
|
||||
if !usernamePattern.MatchString(username) {
|
||||
return &ValidationError{Field: "username", Message: "invalid characters (allowed: a-z, A-Z, 0-9, _, -, .)"}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidatePassword validates a password
|
||||
func ValidatePassword(password string) error {
|
||||
if password == "" {
|
||||
return &ValidationError{Field: "password", Message: "password cannot be empty"}
|
||||
}
|
||||
|
||||
if len(password) < 8 {
|
||||
return &ValidationError{Field: "password", Message: "password too short (min 8 characters)"}
|
||||
}
|
||||
|
||||
if len(password) > 128 {
|
||||
return &ValidationError{Field: "password", Message: "password too long (max 128 characters)"}
|
||||
}
|
||||
|
||||
// Check for at least one letter and one number
|
||||
hasLetter := false
|
||||
hasNumber := false
|
||||
|
||||
for _, r := range password {
|
||||
if unicode.IsLetter(r) {
|
||||
hasLetter = true
|
||||
}
|
||||
if unicode.IsNumber(r) {
|
||||
hasNumber = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasLetter {
|
||||
return &ValidationError{Field: "password", Message: "password must contain at least one letter"}
|
||||
}
|
||||
|
||||
if !hasNumber {
|
||||
return &ValidationError{Field: "password", Message: "password must contain at least one number"}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateEmail validates an email address
|
||||
func ValidateEmail(email string) error {
|
||||
if email == "" {
|
||||
return nil // Email is optional
|
||||
}
|
||||
|
||||
if len(email) > 254 {
|
||||
return &ValidationError{Field: "email", Message: "email too long (max 254 characters)"}
|
||||
}
|
||||
|
||||
if !emailPattern.MatchString(email) {
|
||||
return &ValidationError{Field: "email", Message: "invalid email format"}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateShareName validates an SMB share name
|
||||
func ValidateShareName(name string) error {
|
||||
if name == "" {
|
||||
return &ValidationError{Field: "name", Message: "share name cannot be empty"}
|
||||
}
|
||||
|
||||
if len(name) > 80 {
|
||||
return &ValidationError{Field: "name", Message: "share name too long (max 80 characters)"}
|
||||
}
|
||||
|
||||
if !shareNamePattern.MatchString(name) {
|
||||
return &ValidationError{Field: "name", Message: "invalid share name (allowed: a-z, A-Z, 0-9, _, -, .)"}
|
||||
}
|
||||
|
||||
// Reserved names
|
||||
reserved := []string{"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"}
|
||||
upperName := strings.ToUpper(name)
|
||||
for _, r := range reserved {
|
||||
if upperName == r {
|
||||
return &ValidationError{Field: "name", Message: fmt.Sprintf("share name '%s' is reserved", name)}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateIQN validates an iSCSI Qualified Name
|
||||
func ValidateIQN(iqn string) error {
|
||||
if iqn == "" {
|
||||
return &ValidationError{Field: "iqn", Message: "IQN cannot be empty"}
|
||||
}
|
||||
|
||||
if len(iqn) > 223 {
|
||||
return &ValidationError{Field: "iqn", Message: "IQN too long (max 223 characters)"}
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(iqn, "iqn.") {
|
||||
return &ValidationError{Field: "iqn", Message: "IQN must start with 'iqn.'"}
|
||||
}
|
||||
|
||||
// Basic format validation (can be more strict)
|
||||
if !iqnPattern.MatchString(iqn) {
|
||||
return &ValidationError{Field: "iqn", Message: "invalid IQN format (expected: iqn.yyyy-mm.reversed.domain:identifier)"}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateSize validates a size string (e.g., "10G", "1T")
|
||||
func ValidateSize(sizeStr string) error {
|
||||
if sizeStr == "" {
|
||||
return &ValidationError{Field: "size", Message: "size cannot be empty"}
|
||||
}
|
||||
|
||||
// Pattern: number followed by optional unit (K, M, G, T, P)
|
||||
sizePattern := regexp.MustCompile(`^(\d+)([KMGT]?)$`)
|
||||
if !sizePattern.MatchString(strings.ToUpper(sizeStr)) {
|
||||
return &ValidationError{Field: "size", Message: "invalid size format (expected: number with optional unit K, M, G, T, P)"}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidatePath validates a filesystem path
|
||||
func ValidatePath(path string) error {
|
||||
if path == "" {
|
||||
return nil // Path is optional (can be auto-filled)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
return &ValidationError{Field: "path", Message: "path must be absolute (start with /)"}
|
||||
}
|
||||
|
||||
if len(path) > 4096 {
|
||||
return &ValidationError{Field: "path", Message: "path too long (max 4096 characters)"}
|
||||
}
|
||||
|
||||
// Check for dangerous path components
|
||||
dangerous := []string{"..", "//", "\x00"}
|
||||
for _, d := range dangerous {
|
||||
if strings.Contains(path, d) {
|
||||
return &ValidationError{Field: "path", Message: fmt.Sprintf("path contains invalid component: %s", d)}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateCIDR validates a CIDR notation or hostname
|
||||
func ValidateCIDR(cidr string) error {
|
||||
if cidr == "" {
|
||||
return &ValidationError{Field: "client", Message: "client cannot be empty"}
|
||||
}
|
||||
|
||||
// Allow wildcard
|
||||
if cidr == "*" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if it's a CIDR
|
||||
if cidrPattern.MatchString(cidr) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if it's a valid hostname
|
||||
hostnamePattern := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$`)
|
||||
if hostnamePattern.MatchString(cidr) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &ValidationError{Field: "client", Message: "invalid client format (expected: CIDR, hostname, or '*')"}
|
||||
}
|
||||
|
||||
// SanitizeString removes potentially dangerous characters
|
||||
func SanitizeString(s string) string {
|
||||
// Remove null bytes and control characters
|
||||
var result strings.Builder
|
||||
for _, r := range s {
|
||||
if r >= 32 && r != 127 {
|
||||
result.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(result.String())
|
||||
}
|
||||
|
||||
// SanitizePath sanitizes a filesystem path
|
||||
func SanitizePath(path string) string {
|
||||
// Remove leading/trailing whitespace and normalize slashes
|
||||
path = strings.TrimSpace(path)
|
||||
path = strings.ReplaceAll(path, "\\", "/")
|
||||
|
||||
// Remove multiple slashes
|
||||
for strings.Contains(path, "//") {
|
||||
path = strings.ReplaceAll(path, "//", "/")
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
278
internal/validation/validator_test.go
Normal file
278
internal/validation/validator_test.go
Normal file
@@ -0,0 +1,278 @@
|
||||
package validation
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateZFSName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid pool name", "tank", false},
|
||||
{"valid dataset name", "tank/data", false},
|
||||
{"valid nested dataset", "tank/data/subdata", false},
|
||||
{"valid with underscore", "tank_data", false},
|
||||
{"valid with dash", "tank-data", false},
|
||||
{"valid with colon", "tank:data", false},
|
||||
{"empty name", "", true},
|
||||
{"starts with dash", "-tank", true},
|
||||
{"starts with dot", ".tank", true},
|
||||
{"invalid character @", "tank@data", true},
|
||||
{"too long", string(make([]byte, 257)), true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateZFSName(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateZFSName(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUsername(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid username", "admin", false},
|
||||
{"valid with underscore", "admin_user", false},
|
||||
{"valid with dash", "admin-user", false},
|
||||
{"valid with dot", "admin.user", false},
|
||||
{"too short", "ab", true},
|
||||
{"too long", string(make([]byte, 33)), true},
|
||||
{"empty", "", true},
|
||||
{"invalid character", "admin@user", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateUsername(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateUsername(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePassword(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid password", "SecurePass123", false},
|
||||
{"valid with special chars", "Secure!Pass123", false},
|
||||
{"too short", "Short1", true},
|
||||
{"no letter", "12345678", true},
|
||||
{"no number", "SecurePass", true},
|
||||
{"empty", "", true},
|
||||
{"too long", string(make([]byte, 129)), true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidatePassword(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidatePassword(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEmail(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid email", "user@example.com", false},
|
||||
{"valid with subdomain", "user@mail.example.com", false},
|
||||
{"empty (optional)", "", false},
|
||||
{"invalid format", "notanemail", true},
|
||||
{"missing @", "user.example.com", true},
|
||||
{"missing domain", "user@", true},
|
||||
{"too long", string(make([]byte, 255)), true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateEmail(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateEmail(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateShareName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid share name", "data-share", false},
|
||||
{"valid with underscore", "data_share", false},
|
||||
{"reserved name CON", "CON", true},
|
||||
{"reserved name COM1", "COM1", true},
|
||||
{"too long", string(make([]byte, 81)), true},
|
||||
{"empty", "", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateShareName(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateShareName(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateIQN(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid IQN", "iqn.2024-12.com.atlas:target1", false},
|
||||
{"invalid format", "iqn.2024-12", true},
|
||||
{"missing iqn prefix", "2024-12.com.atlas:target1", true},
|
||||
{"invalid date format", "iqn.2024-1.com.atlas:target1", true},
|
||||
{"empty", "", true},
|
||||
{"too long", string(make([]byte, 224)), true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateIQN(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateIQN(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSize(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid size bytes", "1024", false},
|
||||
{"valid size KB", "10K", false},
|
||||
{"valid size MB", "100M", false},
|
||||
{"valid size GB", "1G", false},
|
||||
{"valid size TB", "2T", false},
|
||||
{"lowercase unit", "1g", false},
|
||||
{"invalid unit", "10X", true},
|
||||
{"empty", "", true},
|
||||
{"no number", "G", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateSize(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateSize(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid absolute path", "/tank/data", false},
|
||||
{"valid root path", "/", false},
|
||||
{"empty (optional)", "", false},
|
||||
{"relative path", "tank/data", true},
|
||||
{"path traversal", "/tank/../data", true},
|
||||
{"double slash", "/tank//data", true},
|
||||
{"too long", "/" + string(make([]byte, 4096)), true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidatePath(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidatePath(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCIDR(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid CIDR", "192.168.1.0/24", false},
|
||||
{"valid IP", "192.168.1.1", false},
|
||||
{"wildcard", "*", false},
|
||||
{"valid hostname", "server.example.com", false},
|
||||
{"invalid format", "not@a@valid@format", true},
|
||||
{"empty", "", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateCIDR(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateCIDR(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"normal string", "hello world", "hello world"},
|
||||
{"with null byte", "hello\x00world", "helloworld"},
|
||||
{"with control chars", "hello\x01\x02world", "helloworld"},
|
||||
{"with whitespace", " hello world ", "hello world"},
|
||||
{"empty", "", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := SanitizeString(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("SanitizeString(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"normal path", "/tank/data", "/tank/data"},
|
||||
{"with backslash", "/tank\\data", "/tank/data"},
|
||||
{"with double slash", "/tank//data", "/tank/data"},
|
||||
{"with whitespace", " /tank/data ", "/tank/data"},
|
||||
{"multiple slashes", "/tank///data", "/tank/data"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := SanitizePath(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("SanitizePath(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
727
internal/zfs/service.go
Normal file
727
internal/zfs/service.go
Normal file
@@ -0,0 +1,727 @@
|
||||
package zfs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/models"
|
||||
)
|
||||
|
||||
// Service provides ZFS operations
|
||||
type Service struct {
|
||||
zfsPath string
|
||||
zpoolPath string
|
||||
}
|
||||
|
||||
// New creates a new ZFS service
|
||||
func New() *Service {
|
||||
// Find full paths to zfs and zpool commands
|
||||
zfsPath := findCommandPath("zfs")
|
||||
zpoolPath := findCommandPath("zpool")
|
||||
|
||||
return &Service{
|
||||
zfsPath: zfsPath,
|
||||
zpoolPath: zpoolPath,
|
||||
}
|
||||
}
|
||||
|
||||
// findCommandPath finds the full path to a command
|
||||
func findCommandPath(cmd string) string {
|
||||
// Try which first
|
||||
if output, err := exec.Command("which", cmd).Output(); err == nil {
|
||||
path := strings.TrimSpace(string(output))
|
||||
if path != "" {
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
// Try LookPath
|
||||
if path, err := exec.LookPath(cmd); err == nil {
|
||||
return path
|
||||
}
|
||||
|
||||
// Fallback to command name (will use PATH)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// execCommand executes a shell command and returns output
|
||||
// For ZFS operations that require elevated privileges, it uses sudo
|
||||
func (s *Service) execCommand(name string, args ...string) (string, error) {
|
||||
// Commands that require root privileges
|
||||
privilegedCommands := []string{"zpool", "zfs"}
|
||||
useSudo := false
|
||||
|
||||
for _, cmd := range privilegedCommands {
|
||||
if strings.Contains(name, cmd) {
|
||||
useSudo = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var cmd *exec.Cmd
|
||||
if useSudo {
|
||||
// Use sudo -n (non-interactive) for privileged commands
|
||||
// This prevents password prompts and will fail if sudoers is not configured
|
||||
sudoArgs := append([]string{"-n", name}, args...)
|
||||
cmd = exec.Command("sudo", sudoArgs...)
|
||||
} else {
|
||||
cmd = exec.Command(name, args...)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
if err != nil && useSudo {
|
||||
// If sudo failed, try running the command directly
|
||||
// (user might already have permissions or be root)
|
||||
directCmd := exec.Command(name, args...)
|
||||
var directStdout, directStderr bytes.Buffer
|
||||
directCmd.Stdout = &directStdout
|
||||
directCmd.Stderr = &directStderr
|
||||
|
||||
if directErr := directCmd.Run(); directErr == nil {
|
||||
// Direct execution succeeded, return that result
|
||||
return strings.TrimSpace(directStdout.String()), nil
|
||||
}
|
||||
// Both sudo and direct failed, return the original sudo error
|
||||
return "", fmt.Errorf("%s: %v: %s", name, err, stderr.String())
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: %v: %s", name, err, stderr.String())
|
||||
}
|
||||
|
||||
return strings.TrimSpace(stdout.String()), nil
|
||||
}
|
||||
|
||||
// ListPools returns all ZFS pools
|
||||
func (s *Service) ListPools() ([]models.Pool, error) {
|
||||
output, err := s.execCommand(s.zpoolPath, "list", "-H", "-o", "name,size,allocated,free,health")
|
||||
if err != nil {
|
||||
// Return empty slice instead of nil to ensure JSON encodes as [] not null
|
||||
return []models.Pool{}, err
|
||||
}
|
||||
|
||||
pools := []models.Pool{}
|
||||
lines := strings.Split(output, "\n")
|
||||
for _, line := range lines {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 5 {
|
||||
continue
|
||||
}
|
||||
|
||||
pool := models.Pool{
|
||||
Name: fields[0],
|
||||
Status: "ONLINE", // Default, will be updated from health
|
||||
Health: fields[4],
|
||||
}
|
||||
|
||||
// Parse sizes (handles K, M, G, T suffixes)
|
||||
if size, err := parseSize(fields[1]); err == nil {
|
||||
pool.Size = size
|
||||
}
|
||||
if allocated, err := parseSize(fields[2]); err == nil {
|
||||
pool.Allocated = allocated
|
||||
}
|
||||
if free, err := parseSize(fields[3]); err == nil {
|
||||
pool.Free = free
|
||||
}
|
||||
|
||||
// Get pool status
|
||||
status, _ := s.execCommand(s.zpoolPath, "status", "-x", pool.Name)
|
||||
if strings.Contains(status, "all pools are healthy") {
|
||||
pool.Status = "ONLINE"
|
||||
} else if strings.Contains(status, "DEGRADED") {
|
||||
pool.Status = "DEGRADED"
|
||||
} else if strings.Contains(status, "FAULTED") {
|
||||
pool.Status = "FAULTED"
|
||||
}
|
||||
|
||||
// Get creation time
|
||||
created, _ := s.execCommand(s.zfsPath, "get", "-H", "-o", "value", "creation", pool.Name)
|
||||
if t, err := time.Parse("Mon Jan 2 15:04:05 2006", created); err == nil {
|
||||
pool.CreatedAt = t
|
||||
}
|
||||
|
||||
pools = append(pools, pool)
|
||||
}
|
||||
|
||||
return pools, nil
|
||||
}
|
||||
|
||||
// GetPool returns a specific pool
|
||||
func (s *Service) GetPool(name string) (*models.Pool, error) {
|
||||
pools, err := s.ListPools()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, pool := range pools {
|
||||
if pool.Name == name {
|
||||
return &pool, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("pool %s not found", name)
|
||||
}
|
||||
|
||||
// CreatePool creates a new ZFS pool
|
||||
func (s *Service) CreatePool(name string, vdevs []string, options map[string]string) error {
|
||||
args := []string{"create"}
|
||||
|
||||
// Add options
|
||||
for k, v := range options {
|
||||
args = append(args, "-o", fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
|
||||
args = append(args, name)
|
||||
args = append(args, vdevs...)
|
||||
|
||||
_, err := s.execCommand(s.zpoolPath, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// DestroyPool destroys a ZFS pool
|
||||
func (s *Service) DestroyPool(name string) error {
|
||||
_, err := s.execCommand(s.zpoolPath, "destroy", name)
|
||||
return err
|
||||
}
|
||||
|
||||
// ImportPool imports a ZFS pool
|
||||
func (s *Service) ImportPool(name string, options map[string]string) error {
|
||||
args := []string{"import"}
|
||||
|
||||
// Add options
|
||||
for k, v := range options {
|
||||
args = append(args, "-o", fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
|
||||
args = append(args, name)
|
||||
_, err := s.execCommand(s.zpoolPath, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// ExportPool exports a ZFS pool
|
||||
func (s *Service) ExportPool(name string, force bool) error {
|
||||
args := []string{"export"}
|
||||
if force {
|
||||
args = append(args, "-f")
|
||||
}
|
||||
args = append(args, name)
|
||||
_, err := s.execCommand(s.zpoolPath, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListAvailablePools returns pools that can be imported
|
||||
func (s *Service) ListAvailablePools() ([]string, error) {
|
||||
output, err := s.execCommand(s.zpoolPath, "import")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var pools []string
|
||||
lines := strings.Split(output, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// Parse pool name from output like "pool: tank"
|
||||
if strings.HasPrefix(line, "pool:") {
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 2 {
|
||||
pools = append(pools, parts[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pools, nil
|
||||
}
|
||||
|
||||
// ScrubPool starts a scrub operation on a pool
|
||||
func (s *Service) ScrubPool(name string) error {
|
||||
_, err := s.execCommand(s.zpoolPath, "scrub", name)
|
||||
return err
|
||||
}
|
||||
|
||||
// ScrubStatus represents detailed scrub operation status
|
||||
type ScrubStatus struct {
|
||||
Status string `json:"status"` // idle, in_progress, completed, error
|
||||
Progress float64 `json:"progress"` // 0-100
|
||||
TimeElapsed string `json:"time_elapsed"` // e.g., "2h 15m"
|
||||
TimeRemain string `json:"time_remain"` // e.g., "30m"
|
||||
Speed string `json:"speed"` // e.g., "100M/s"
|
||||
Errors int `json:"errors"` // number of errors found
|
||||
Repaired int `json:"repaired"` // number of errors repaired
|
||||
LastScrub string `json:"last_scrub"` // timestamp of last completed scrub
|
||||
}
|
||||
|
||||
// GetScrubStatus returns detailed scrub status with progress
|
||||
func (s *Service) GetScrubStatus(name string) (*ScrubStatus, error) {
|
||||
status := &ScrubStatus{
|
||||
Status: "idle",
|
||||
}
|
||||
|
||||
// Get pool status
|
||||
output, err := s.execCommand(s.zpoolPath, "status", name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse scrub information
|
||||
lines := strings.Split(output, "\n")
|
||||
inScrubSection := false
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
// Check if scrub is in progress
|
||||
if strings.Contains(line, "scrub in progress") {
|
||||
status.Status = "in_progress"
|
||||
inScrubSection = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if scrub completed
|
||||
if strings.Contains(line, "scrub repaired") || strings.Contains(line, "scrub completed") {
|
||||
status.Status = "completed"
|
||||
status.Progress = 100.0
|
||||
// Extract repair information
|
||||
if strings.Contains(line, "repaired") {
|
||||
// Try to extract number of repairs
|
||||
parts := strings.Fields(line)
|
||||
for i, part := range parts {
|
||||
if part == "repaired" && i > 0 {
|
||||
// Previous part might be the number
|
||||
if repaired, err := strconv.Atoi(parts[i-1]); err == nil {
|
||||
status.Repaired = repaired
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse progress percentage
|
||||
if strings.Contains(line, "%") && inScrubSection {
|
||||
// Extract percentage from line like "scan: 45.2% done"
|
||||
parts := strings.Fields(line)
|
||||
for _, part := range parts {
|
||||
if strings.HasSuffix(part, "%") {
|
||||
if pct, err := strconv.ParseFloat(strings.TrimSuffix(part, "%"), 64); err == nil {
|
||||
status.Progress = pct
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse time elapsed
|
||||
if strings.Contains(line, "elapsed") && inScrubSection {
|
||||
// Extract time like "elapsed: 2h15m"
|
||||
parts := strings.Fields(line)
|
||||
for i, part := range parts {
|
||||
if part == "elapsed:" && i+1 < len(parts) {
|
||||
status.TimeElapsed = parts[i+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse time remaining
|
||||
if strings.Contains(line, "remaining") && inScrubSection {
|
||||
parts := strings.Fields(line)
|
||||
for i, part := range parts {
|
||||
if part == "remaining:" && i+1 < len(parts) {
|
||||
status.TimeRemain = parts[i+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse speed
|
||||
if strings.Contains(line, "scan rate") && inScrubSection {
|
||||
parts := strings.Fields(line)
|
||||
for i, part := range parts {
|
||||
if part == "rate" && i+1 < len(parts) {
|
||||
status.Speed = parts[i+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse errors
|
||||
if strings.Contains(line, "errors:") && inScrubSection {
|
||||
parts := strings.Fields(line)
|
||||
for i, part := range parts {
|
||||
if part == "errors:" && i+1 < len(parts) {
|
||||
if errs, err := strconv.Atoi(parts[i+1]); err == nil {
|
||||
status.Errors = errs
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get last scrub time from pool properties
|
||||
lastScrub, err := s.execCommand(s.zfsPath, "get", "-H", "-o", "value", "lastscrub", name)
|
||||
if err == nil && lastScrub != "-" && lastScrub != "" {
|
||||
status.LastScrub = strings.TrimSpace(lastScrub)
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// ListDatasets returns all datasets in a pool (or all if pool is empty)
|
||||
func (s *Service) ListDatasets(pool string) ([]models.Dataset, error) {
|
||||
args := []string{"list", "-H", "-o", "name,type,used,avail,mountpoint"}
|
||||
if pool != "" {
|
||||
args = append(args, "-r", pool)
|
||||
} else {
|
||||
args = append(args, "-r")
|
||||
}
|
||||
|
||||
output, err := s.execCommand(s.zfsPath, args...)
|
||||
if err != nil {
|
||||
// Return empty slice instead of nil to ensure JSON encodes as [] not null
|
||||
return []models.Dataset{}, err
|
||||
}
|
||||
|
||||
datasets := []models.Dataset{}
|
||||
lines := strings.Split(output, "\n")
|
||||
for _, line := range lines {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 5 {
|
||||
continue
|
||||
}
|
||||
|
||||
fullName := fields[0]
|
||||
parts := strings.Split(fullName, "/")
|
||||
poolName := parts[0]
|
||||
|
||||
dataset := models.Dataset{
|
||||
Name: fullName,
|
||||
Pool: poolName,
|
||||
Type: fields[1],
|
||||
Mountpoint: fields[4],
|
||||
}
|
||||
|
||||
if used, err := parseSize(fields[2]); err == nil {
|
||||
dataset.Used = used
|
||||
}
|
||||
if avail, err := parseSize(fields[3]); err == nil {
|
||||
dataset.Available = avail
|
||||
}
|
||||
dataset.Size = dataset.Used + dataset.Available
|
||||
|
||||
// Get creation time
|
||||
created, _ := s.execCommand(s.zfsPath, "get", "-H", "-o", "value", "creation", fullName)
|
||||
if t, err := time.Parse("Mon Jan 2 15:04:05 2006", created); err == nil {
|
||||
dataset.CreatedAt = t
|
||||
}
|
||||
|
||||
datasets = append(datasets, dataset)
|
||||
}
|
||||
|
||||
return datasets, nil
|
||||
}
|
||||
|
||||
// CreateDataset creates a new ZFS dataset
|
||||
func (s *Service) CreateDataset(name string, options map[string]string) error {
|
||||
args := []string{"create"}
|
||||
|
||||
for k, v := range options {
|
||||
args = append(args, "-o", fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
|
||||
args = append(args, name)
|
||||
_, err := s.execCommand(s.zfsPath, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// DestroyDataset destroys a ZFS dataset
|
||||
func (s *Service) DestroyDataset(name string, recursive bool) error {
|
||||
args := []string{"destroy"}
|
||||
if recursive {
|
||||
args = append(args, "-r")
|
||||
}
|
||||
args = append(args, name)
|
||||
_, err := s.execCommand(s.zfsPath, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListZVOLs returns all ZVOLs
|
||||
func (s *Service) ListZVOLs(pool string) ([]models.ZVOL, error) {
|
||||
args := []string{"list", "-H", "-o", "name,volsize,used", "-t", "volume"}
|
||||
if pool != "" {
|
||||
args = append(args, "-r", pool)
|
||||
} else {
|
||||
args = append(args, "-r")
|
||||
}
|
||||
|
||||
output, err := s.execCommand(s.zfsPath, args...)
|
||||
if err != nil {
|
||||
// Return empty slice instead of nil to ensure JSON encodes as [] not null
|
||||
return []models.ZVOL{}, err
|
||||
}
|
||||
|
||||
zvols := []models.ZVOL{}
|
||||
lines := strings.Split(output, "\n")
|
||||
for _, line := range lines {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 3 {
|
||||
continue
|
||||
}
|
||||
|
||||
fullName := fields[0]
|
||||
parts := strings.Split(fullName, "/")
|
||||
poolName := parts[0]
|
||||
|
||||
zvol := models.ZVOL{
|
||||
Name: fullName,
|
||||
Pool: poolName,
|
||||
}
|
||||
|
||||
if size, err := parseSize(fields[1]); err == nil {
|
||||
zvol.Size = size
|
||||
}
|
||||
if used, err := parseSize(fields[2]); err == nil {
|
||||
zvol.Used = used
|
||||
}
|
||||
|
||||
// Get creation time
|
||||
created, _ := s.execCommand(s.zfsPath, "get", "-H", "-o", "value", "creation", fullName)
|
||||
if t, err := time.Parse("Mon Jan 2 15:04:05 2006", created); err == nil {
|
||||
zvol.CreatedAt = t
|
||||
}
|
||||
|
||||
zvols = append(zvols, zvol)
|
||||
}
|
||||
|
||||
return zvols, nil
|
||||
}
|
||||
|
||||
// CreateZVOL creates a new ZVOL
|
||||
func (s *Service) CreateZVOL(name string, size uint64, options map[string]string) error {
|
||||
args := []string{"create", "-V", fmt.Sprintf("%d", size)}
|
||||
|
||||
for k, v := range options {
|
||||
args = append(args, "-o", fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
|
||||
args = append(args, name)
|
||||
_, err := s.execCommand(s.zfsPath, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// DestroyZVOL destroys a ZVOL
|
||||
func (s *Service) DestroyZVOL(name string) error {
|
||||
_, err := s.execCommand(s.zfsPath, "destroy", name)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListDisks returns available disks (read-only)
|
||||
func (s *Service) ListDisks() ([]map[string]string, error) {
|
||||
// Use lsblk to list block devices
|
||||
output, err := s.execCommand("lsblk", "-J", "-o", "name,size,type,fstype,mountpoint")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
BlockDevices []struct {
|
||||
Name string `json:"name"`
|
||||
Size string `json:"size"`
|
||||
Type string `json:"type"`
|
||||
FSType string `json:"fstype"`
|
||||
Mountpoint string `json:"mountpoint"`
|
||||
Children []interface{} `json:"children"`
|
||||
} `json:"blockdevices"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(output), &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var disks []map[string]string
|
||||
for _, dev := range result.BlockDevices {
|
||||
if dev.Type == "disk" && dev.FSType == "" && dev.Mountpoint == "" {
|
||||
disks = append(disks, map[string]string{
|
||||
"name": dev.Name,
|
||||
"size": dev.Size,
|
||||
"path": "/dev/" + dev.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return disks, nil
|
||||
}
|
||||
|
||||
// parseSize converts human-readable size to bytes
|
||||
func parseSize(s string) (uint64, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "-" || s == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
multiplier := uint64(1)
|
||||
suffix := strings.ToUpper(s[len(s)-1:])
|
||||
|
||||
switch suffix {
|
||||
case "K":
|
||||
multiplier = 1024
|
||||
s = s[:len(s)-1]
|
||||
case "M":
|
||||
multiplier = 1024 * 1024
|
||||
s = s[:len(s)-1]
|
||||
case "G":
|
||||
multiplier = 1024 * 1024 * 1024
|
||||
s = s[:len(s)-1]
|
||||
case "T":
|
||||
multiplier = 1024 * 1024 * 1024 * 1024
|
||||
s = s[:len(s)-1]
|
||||
case "P":
|
||||
multiplier = 1024 * 1024 * 1024 * 1024 * 1024
|
||||
s = s[:len(s)-1]
|
||||
default:
|
||||
// Check if last char is a digit
|
||||
if suffix[0] < '0' || suffix[0] > '9' {
|
||||
return 0, fmt.Errorf("unknown suffix: %s", suffix)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle decimal values (e.g., "1.5G")
|
||||
if strings.Contains(s, ".") {
|
||||
val, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return uint64(val * float64(multiplier)), nil
|
||||
}
|
||||
|
||||
val, err := strconv.ParseUint(s, 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return val * multiplier, nil
|
||||
}
|
||||
|
||||
// ListSnapshots returns all snapshots for a dataset (or all if dataset is empty)
|
||||
func (s *Service) ListSnapshots(dataset string) ([]models.Snapshot, error) {
|
||||
args := []string{"list", "-H", "-o", "name,used,creation", "-t", "snapshot", "-s", "creation"}
|
||||
if dataset != "" {
|
||||
args = append(args, "-r", dataset)
|
||||
} else {
|
||||
args = append(args, "-r")
|
||||
}
|
||||
|
||||
output, err := s.execCommand(s.zfsPath, args...)
|
||||
if err != nil {
|
||||
// Return empty slice instead of nil to ensure JSON encodes as [] not null
|
||||
return []models.Snapshot{}, err
|
||||
}
|
||||
|
||||
snapshots := []models.Snapshot{}
|
||||
lines := strings.Split(output, "\n")
|
||||
for _, line := range lines {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 3 {
|
||||
continue
|
||||
}
|
||||
|
||||
fullName := fields[0]
|
||||
// Snapshot name format: dataset@snapshot
|
||||
parts := strings.Split(fullName, "@")
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
datasetName := parts[0]
|
||||
|
||||
snapshot := models.Snapshot{
|
||||
Name: fullName,
|
||||
Dataset: datasetName,
|
||||
}
|
||||
|
||||
// Parse size
|
||||
if used, err := parseSize(fields[1]); err == nil {
|
||||
snapshot.Size = used
|
||||
}
|
||||
|
||||
// Parse creation time
|
||||
// ZFS creation format: "Mon Jan 2 15:04:05 2006"
|
||||
createdStr := strings.Join(fields[2:], " ")
|
||||
if t, err := time.Parse("Mon Jan 2 15:04:05 2006", createdStr); err == nil {
|
||||
snapshot.CreatedAt = t
|
||||
} else {
|
||||
// Try RFC3339 format if available
|
||||
if t, err := time.Parse(time.RFC3339, createdStr); err == nil {
|
||||
snapshot.CreatedAt = t
|
||||
}
|
||||
}
|
||||
|
||||
snapshots = append(snapshots, snapshot)
|
||||
}
|
||||
|
||||
return snapshots, nil
|
||||
}
|
||||
|
||||
// CreateSnapshot creates a new snapshot
|
||||
func (s *Service) CreateSnapshot(dataset, name string, recursive bool) error {
|
||||
args := []string{"snapshot"}
|
||||
if recursive {
|
||||
args = append(args, "-r")
|
||||
}
|
||||
|
||||
snapshotName := fmt.Sprintf("%s@%s", dataset, name)
|
||||
args = append(args, snapshotName)
|
||||
|
||||
_, err := s.execCommand(s.zfsPath, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// DestroySnapshot destroys a snapshot
|
||||
func (s *Service) DestroySnapshot(name string, recursive bool) error {
|
||||
args := []string{"destroy"}
|
||||
if recursive {
|
||||
args = append(args, "-r")
|
||||
}
|
||||
args = append(args, name)
|
||||
_, err := s.execCommand(s.zfsPath, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetSnapshot returns snapshot details
|
||||
func (s *Service) GetSnapshot(name string) (*models.Snapshot, error) {
|
||||
snapshots, err := s.ListSnapshots("")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, snap := range snapshots {
|
||||
if snap.Name == name {
|
||||
return &snap, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("snapshot %s not found", name)
|
||||
}
|
||||
179
test/integration_test.go
Normal file
179
test/integration_test.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/httpapp"
|
||||
)
|
||||
|
||||
// TestServer provides an integration test server
|
||||
type TestServer struct {
|
||||
App *httpapp.App
|
||||
Server *httptest.Server
|
||||
Client *http.Client
|
||||
AuthToken string
|
||||
}
|
||||
|
||||
// NewTestServer creates a new test server
|
||||
func NewTestServer(t *testing.T) *TestServer {
|
||||
// Use absolute paths or create templates directory for tests
|
||||
templatesDir := "web/templates"
|
||||
staticDir := "web/static"
|
||||
|
||||
app, err := httpapp.New(httpapp.Config{
|
||||
Addr: ":0", // Use random port
|
||||
TemplatesDir: templatesDir,
|
||||
StaticDir: staticDir,
|
||||
DatabasePath: "", // Empty = in-memory mode (no database)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create test app: %v", err)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(app.Router())
|
||||
|
||||
return &TestServer{
|
||||
App: app,
|
||||
Server: server,
|
||||
Client: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
// Close shuts down the test server
|
||||
func (ts *TestServer) Close() {
|
||||
ts.Server.Close()
|
||||
ts.App.StopScheduler()
|
||||
}
|
||||
|
||||
// Login performs a login and stores the auth token
|
||||
func (ts *TestServer) Login(t *testing.T, username, password string) {
|
||||
reqBody := map[string]string{
|
||||
"username": username,
|
||||
"password": password,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req, err := http.NewRequest("POST", ts.Server.URL+"/api/v1/auth/login",
|
||||
bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("create login request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := ts.Client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("login request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("login failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("decode login response: %v", err)
|
||||
}
|
||||
|
||||
if token, ok := result["token"].(string); ok {
|
||||
ts.AuthToken = token
|
||||
} else {
|
||||
t.Fatal("no token in login response")
|
||||
}
|
||||
}
|
||||
|
||||
// Get performs an authenticated GET request
|
||||
func (ts *TestServer) Get(t *testing.T, path string) *http.Response {
|
||||
req, err := http.NewRequest("GET", ts.Server.URL+path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create GET request: %v", err)
|
||||
}
|
||||
|
||||
if ts.AuthToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+ts.AuthToken)
|
||||
}
|
||||
|
||||
resp, err := ts.Client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET request: %v", err)
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
// Post performs an authenticated POST request
|
||||
func (ts *TestServer) Post(t *testing.T, path string, body interface{}) *http.Response {
|
||||
bodyBytes, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request body: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", ts.Server.URL+path, bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
t.Fatalf("create POST request: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if ts.AuthToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+ts.AuthToken)
|
||||
}
|
||||
|
||||
resp, err := ts.Client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("POST request: %v", err)
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
// TestHealthCheck tests the health check endpoint
|
||||
func TestHealthCheck(t *testing.T) {
|
||||
ts := NewTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
resp := ts.Get(t, "/healthz")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogin tests the login endpoint
|
||||
func TestLogin(t *testing.T) {
|
||||
ts := NewTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
// Test with default admin credentials
|
||||
ts.Login(t, "admin", "admin")
|
||||
|
||||
if ts.AuthToken == "" {
|
||||
t.Error("expected auth token after login")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnauthorizedAccess tests that protected endpoints require authentication
|
||||
func TestUnauthorizedAccess(t *testing.T) {
|
||||
ts := NewTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
resp := ts.Get(t, "/api/v1/pools")
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("expected status 401, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthenticatedAccess tests that authenticated requests work
|
||||
func TestAuthenticatedAccess(t *testing.T) {
|
||||
ts := NewTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
ts.Login(t, "admin", "admin")
|
||||
|
||||
resp := ts.Get(t, "/api/v1/pools")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
159
test_api.sh
Executable file
159
test_api.sh
Executable file
@@ -0,0 +1,159 @@
|
||||
#!/bin/bash
|
||||
# Atlas API Test Script
|
||||
# This script tests the API endpoints
|
||||
|
||||
BASE_URL="${ATLAS_URL:-http://localhost:8080}"
|
||||
API_URL="${BASE_URL}/api/v1"
|
||||
|
||||
echo "=========================================="
|
||||
echo "Atlas API Test Suite"
|
||||
echo "=========================================="
|
||||
echo "Base URL: $BASE_URL"
|
||||
echo "API URL: $API_URL"
|
||||
echo ""
|
||||
|
||||
# Colors for output
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Test helper function
|
||||
test_endpoint() {
|
||||
local method=$1
|
||||
local endpoint=$2
|
||||
local data=$3
|
||||
local description=$4
|
||||
|
||||
echo -e "${YELLOW}Testing: $description${NC}"
|
||||
echo " $method $endpoint"
|
||||
|
||||
if [ -n "$data" ]; then
|
||||
response=$(curl -s -w "\n%{http_code}" -X "$method" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$data" \
|
||||
"$endpoint")
|
||||
else
|
||||
response=$(curl -s -w "\n%{http_code}" -X "$method" "$endpoint")
|
||||
fi
|
||||
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
body=$(echo "$response" | sed '$d')
|
||||
|
||||
if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then
|
||||
echo -e " ${GREEN}✓ Success (HTTP $http_code)${NC}"
|
||||
echo "$body" | jq '.' 2>/dev/null || echo "$body"
|
||||
elif [ "$http_code" -ge 400 ] && [ "$http_code" -lt 500 ]; then
|
||||
echo -e " ${YELLOW}⚠ Client Error (HTTP $http_code)${NC}"
|
||||
echo "$body" | jq '.' 2>/dev/null || echo "$body"
|
||||
else
|
||||
echo -e " ${RED}✗ Error (HTTP $http_code)${NC}"
|
||||
echo "$body"
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Check if server is running
|
||||
echo "Checking if server is running..."
|
||||
if ! curl -s "$BASE_URL/healthz" > /dev/null; then
|
||||
echo -e "${RED}Error: Server is not running at $BASE_URL${NC}"
|
||||
echo "Start the server with: go run ./cmd/atlas-api/main.go"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}Server is running!${NC}"
|
||||
echo ""
|
||||
|
||||
# Health & Metrics
|
||||
echo "=========================================="
|
||||
echo "1. Health & Metrics"
|
||||
echo "=========================================="
|
||||
test_endpoint "GET" "$BASE_URL/healthz" "" "Health check"
|
||||
test_endpoint "GET" "$BASE_URL/metrics" "" "Prometheus metrics"
|
||||
echo ""
|
||||
|
||||
# Disk Discovery
|
||||
echo "=========================================="
|
||||
echo "2. Disk Discovery"
|
||||
echo "=========================================="
|
||||
test_endpoint "GET" "$API_URL/disks" "" "List available disks"
|
||||
echo ""
|
||||
|
||||
# ZFS Pools
|
||||
echo "=========================================="
|
||||
echo "3. ZFS Pool Management"
|
||||
echo "=========================================="
|
||||
test_endpoint "GET" "$API_URL/pools" "" "List ZFS pools"
|
||||
test_endpoint "GET" "$API_URL/pools/testpool" "" "Get pool details (if exists)"
|
||||
echo ""
|
||||
|
||||
# Datasets
|
||||
echo "=========================================="
|
||||
echo "4. Dataset Management"
|
||||
echo "=========================================="
|
||||
test_endpoint "GET" "$API_URL/datasets" "" "List all datasets"
|
||||
test_endpoint "GET" "$API_URL/datasets?pool=testpool" "" "List datasets in pool"
|
||||
echo ""
|
||||
|
||||
# ZVOLs
|
||||
echo "=========================================="
|
||||
echo "5. ZVOL Management"
|
||||
echo "=========================================="
|
||||
test_endpoint "GET" "$API_URL/zvols" "" "List all ZVOLs"
|
||||
test_endpoint "GET" "$API_URL/zvols?pool=testpool" "" "List ZVOLs in pool"
|
||||
echo ""
|
||||
|
||||
# Snapshots
|
||||
echo "=========================================="
|
||||
echo "6. Snapshot Management"
|
||||
echo "=========================================="
|
||||
test_endpoint "GET" "$API_URL/snapshots" "" "List all snapshots"
|
||||
test_endpoint "GET" "$API_URL/snapshots?dataset=testpool/test" "" "List snapshots for dataset"
|
||||
echo ""
|
||||
|
||||
# Snapshot Policies
|
||||
echo "=========================================="
|
||||
echo "7. Snapshot Policies"
|
||||
echo "=========================================="
|
||||
test_endpoint "GET" "$API_URL/snapshot-policies" "" "List snapshot policies"
|
||||
|
||||
# Create a test policy
|
||||
test_endpoint "POST" "$API_URL/snapshot-policies" \
|
||||
'{"dataset":"testpool/test","frequent":4,"hourly":24,"daily":7,"weekly":4,"monthly":12,"yearly":2,"autosnap":true,"autoprune":true}' \
|
||||
"Create snapshot policy"
|
||||
|
||||
test_endpoint "GET" "$API_URL/snapshot-policies/testpool/test" "" "Get snapshot policy"
|
||||
echo ""
|
||||
|
||||
# Storage Services
|
||||
echo "=========================================="
|
||||
echo "8. Storage Services"
|
||||
echo "=========================================="
|
||||
test_endpoint "GET" "$API_URL/shares/smb" "" "List SMB shares"
|
||||
test_endpoint "GET" "$API_URL/exports/nfs" "" "List NFS exports"
|
||||
test_endpoint "GET" "$API_URL/iscsi/targets" "" "List iSCSI targets"
|
||||
echo ""
|
||||
|
||||
# Jobs
|
||||
echo "=========================================="
|
||||
echo "9. Job Management"
|
||||
echo "=========================================="
|
||||
test_endpoint "GET" "$API_URL/jobs" "" "List jobs"
|
||||
echo ""
|
||||
|
||||
# Users & Auth
|
||||
echo "=========================================="
|
||||
echo "10. Authentication & Users"
|
||||
echo "=========================================="
|
||||
test_endpoint "GET" "$API_URL/users" "" "List users"
|
||||
echo ""
|
||||
|
||||
# Audit Logs
|
||||
echo "=========================================="
|
||||
echo "11. Audit Logs"
|
||||
echo "=========================================="
|
||||
test_endpoint "GET" "$API_URL/audit" "" "List audit logs"
|
||||
echo ""
|
||||
|
||||
echo "=========================================="
|
||||
echo "Test Suite Complete!"
|
||||
echo "=========================================="
|
||||
109
web/templates/base.html
Normal file
109
web/templates/base.html
Normal file
@@ -0,0 +1,109 @@
|
||||
{{define "base"}}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>{{.Title}} • AtlasOS</title>
|
||||
|
||||
<!-- v1: Tailwind CDN (later: bundle local) -->
|
||||
<!-- Try multiple CDN sources for better reliability -->
|
||||
<script src="https://cdn.tailwindcss.com" onerror="this.onerror=null;this.src='https://unpkg.com/@tailwindcss/browser@4/dist/tailwind.min.js'"></script>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12/dist/htmx.min.js" onerror="this.onerror=null;this.src='https://cdn.jsdelivr.net/npm/htmx.org@1.9.12/dist/htmx.min.js'"></script>
|
||||
</head>
|
||||
|
||||
<body class="bg-slate-950 text-slate-100">
|
||||
<header class="border-b border-slate-800 bg-slate-950/80 backdrop-blur">
|
||||
<div class="mx-auto max-w-6xl px-4 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-9 w-9 rounded-lg bg-slate-800 flex items-center justify-center font-bold">A</div>
|
||||
<div>
|
||||
<div class="font-semibold leading-tight">AtlasOS</div>
|
||||
<div class="text-xs text-slate-400 leading-tight">Storage Controller v1</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="text-sm text-slate-300 flex items-center gap-4">
|
||||
<a class="hover:text-white" href="/">Dashboard</a>
|
||||
<a class="hover:text-white" href="/storage">Storage</a>
|
||||
<a class="hover:text-white" href="/shares">Shares</a>
|
||||
<a class="hover:text-white" href="/iscsi">iSCSI</a>
|
||||
<a class="hover:text-white" href="/protection">Data Protection</a>
|
||||
<a class="hover:text-white" href="/management">Management</a>
|
||||
<a class="hover:text-white opacity-50 cursor-not-allowed" href="#">Monitoring</a>
|
||||
<span id="auth-status" class="ml-4"></span>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto max-w-6xl px-4 py-8">
|
||||
{{$ct := getContentTemplate .}}
|
||||
{{if eq $ct "storage-content"}}
|
||||
{{template "storage-content" .}}
|
||||
{{else if eq $ct "shares-content"}}
|
||||
{{template "shares-content" .}}
|
||||
{{else if eq $ct "iscsi-content"}}
|
||||
{{template "iscsi-content" .}}
|
||||
{{else if eq $ct "protection-content"}}
|
||||
{{template "protection-content" .}}
|
||||
{{else if eq $ct "management-content"}}
|
||||
{{template "management-content" .}}
|
||||
{{else if eq $ct "login-content"}}
|
||||
{{template "login-content" .}}
|
||||
{{else}}
|
||||
{{template "content" .}}
|
||||
{{end}}
|
||||
</main>
|
||||
|
||||
<footer class="mx-auto max-w-6xl px-4 pb-10 text-xs text-slate-500">
|
||||
<div class="border-t border-slate-800 pt-4 flex items-center justify-between">
|
||||
<span>AtlasOS • {{nowRFC3339}}</span>
|
||||
<span>Build: {{index .Build "version"}}</span>
|
||||
</div>
|
||||
</footer>
|
||||
<script>
|
||||
// Update auth status in navigation
|
||||
function updateAuthStatus() {
|
||||
const authStatusEl = document.getElementById('auth-status');
|
||||
if (!authStatusEl) return;
|
||||
|
||||
const token = localStorage.getItem('atlas_token');
|
||||
const userStr = localStorage.getItem('atlas_user');
|
||||
|
||||
if (token && userStr) {
|
||||
try {
|
||||
const user = JSON.parse(userStr);
|
||||
authStatusEl.innerHTML = `
|
||||
<span class="text-slate-400">${user.username || 'User'}</span>
|
||||
<button onclick="handleLogout()" class="ml-2 px-2 py-1 text-xs bg-slate-700 hover:bg-slate-600 text-white rounded">
|
||||
Logout
|
||||
</button>
|
||||
`;
|
||||
} catch {
|
||||
authStatusEl.innerHTML = `
|
||||
<a href="/login" class="px-2 py-1 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded">Login</a>
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
authStatusEl.innerHTML = `
|
||||
<a href="/login" class="px-2 py-1 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded">Login</a>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
localStorage.removeItem('atlas_token');
|
||||
localStorage.removeItem('atlas_user');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
// Update on page load
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', updateAuthStatus);
|
||||
} else {
|
||||
updateAuthStatus();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
194
web/templates/dashboard.html
Normal file
194
web/templates/dashboard.html
Normal file
@@ -0,0 +1,194 @@
|
||||
{{define "content"}}
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Dashboard</h1>
|
||||
<p class="text-slate-400">Welcome to AtlasOS Storage Controller</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<!-- Storage Pools Card -->
|
||||
<div class="bg-slate-800 rounded-lg p-6 border border-slate-700">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-white">Pools</h2>
|
||||
<div class="h-10 w-10 rounded-lg bg-slate-700 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-white mb-1" id="pool-count">-</p>
|
||||
<p class="text-sm text-slate-400">Storage Pools</p>
|
||||
</div>
|
||||
|
||||
<!-- Storage Capacity Card -->
|
||||
<div class="bg-slate-800 rounded-lg p-6 border border-slate-700">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-white">Capacity</h2>
|
||||
<div class="h-10 w-10 rounded-lg bg-slate-700 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-white mb-1" id="total-capacity">-</p>
|
||||
<p class="text-sm text-slate-400">Total Capacity</p>
|
||||
</div>
|
||||
|
||||
<!-- Shares Card -->
|
||||
<div class="bg-slate-800 rounded-lg p-6 border border-slate-700">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-white">Shares</h2>
|
||||
<div class="h-10 w-10 rounded-lg bg-slate-700 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-white mb-1" id="smb-shares">-</p>
|
||||
<p class="text-sm text-slate-400">SMB + NFS</p>
|
||||
</div>
|
||||
|
||||
<!-- iSCSI Targets Card -->
|
||||
<div class="bg-slate-800 rounded-lg p-6 border border-slate-700">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-white">iSCSI</h2>
|
||||
<div class="h-10 w-10 rounded-lg bg-slate-700 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-white mb-1" id="iscsi-targets">-</p>
|
||||
<p class="text-sm text-slate-400">Active Targets</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- System Status -->
|
||||
<div class="bg-slate-800 rounded-lg p-6 border border-slate-700">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">Service Status</h2>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-slate-300">SMB/Samba</span>
|
||||
<span id="smb-status" class="px-3 py-1 rounded-full text-xs font-medium bg-slate-700 text-slate-300">-</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-slate-300">NFS Server</span>
|
||||
<span id="nfs-status" class="px-3 py-1 rounded-full text-xs font-medium bg-slate-700 text-slate-300">-</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-slate-300">iSCSI Target</span>
|
||||
<span id="iscsi-status" class="px-3 py-1 rounded-full text-xs font-medium bg-slate-700 text-slate-300">-</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-slate-300">API Status</span>
|
||||
<span class="px-3 py-1 rounded-full text-xs font-medium bg-green-900 text-green-300">Online</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Jobs Status -->
|
||||
<div class="bg-slate-800 rounded-lg p-6 border border-slate-700">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">Jobs</h2>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-slate-300">Running</span>
|
||||
<span id="jobs-running" class="text-white font-semibold">-</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-slate-300">Completed</span>
|
||||
<span id="jobs-completed" class="text-white font-semibold">-</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-slate-300">Failed</span>
|
||||
<span id="jobs-failed" class="text-white font-semibold">-</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-slate-300">Total</span>
|
||||
<span id="jobs-total" class="text-white font-semibold">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="bg-slate-800 rounded-lg p-6 border border-slate-700">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">Recent Activity</h2>
|
||||
<div id="recent-logs" class="space-y-2">
|
||||
<p class="text-slate-400 text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Fetch dashboard data and update UI
|
||||
function updateDashboard() {
|
||||
fetch('/api/v1/dashboard')
|
||||
.then(res => {
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(data => {
|
||||
// Update storage stats
|
||||
document.getElementById('pool-count').textContent = data.storage.pool_count || 0;
|
||||
document.getElementById('total-capacity').textContent = formatBytes(data.storage.total_capacity || 0);
|
||||
document.getElementById('smb-shares').textContent = (data.services.smb_shares || 0) + ' / ' + (data.services.nfs_exports || 0);
|
||||
document.getElementById('iscsi-targets').textContent = data.services.iscsi_targets || 0;
|
||||
|
||||
// Update service status
|
||||
updateStatus('smb-status', data.services.smb_status);
|
||||
updateStatus('nfs-status', data.services.nfs_status);
|
||||
updateStatus('iscsi-status', data.services.iscsi_status);
|
||||
|
||||
// Update jobs
|
||||
document.getElementById('jobs-running').textContent = data.jobs.running || 0;
|
||||
document.getElementById('jobs-completed').textContent = data.jobs.completed || 0;
|
||||
document.getElementById('jobs-failed').textContent = data.jobs.failed || 0;
|
||||
document.getElementById('jobs-total').textContent = data.jobs.total || 0;
|
||||
|
||||
// Update recent logs
|
||||
const logsDiv = document.getElementById('recent-logs');
|
||||
if (data.recent_audit_logs && data.recent_audit_logs.length > 0) {
|
||||
logsDiv.innerHTML = data.recent_audit_logs.map(log => `
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-slate-300">${log.action} ${log.resource}</span>
|
||||
<span class="px-2 py-1 rounded text-xs ${log.result === 'success' ? 'bg-green-900 text-green-300' : 'bg-red-900 text-red-300'}">${log.result}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
} else {
|
||||
logsDiv.innerHTML = '<p class="text-slate-400 text-sm">No recent activity</p>';
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('Dashboard update error:', err));
|
||||
}
|
||||
|
||||
function updateStatus(id, status) {
|
||||
const el = document.getElementById(id);
|
||||
if (status) {
|
||||
el.className = 'px-3 py-1 rounded-full text-xs font-medium bg-green-900 text-green-300';
|
||||
el.textContent = 'Running';
|
||||
} else {
|
||||
el.className = 'px-3 py-1 rounded-full text-xs font-medium bg-red-900 text-red-300';
|
||||
el.textContent = 'Stopped';
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// Initial load and periodic updates
|
||||
updateDashboard();
|
||||
setInterval(updateDashboard, 30000); // Update every 30 seconds
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{define "dashboard.html"}}
|
||||
{{template "base" .}}
|
||||
{{end}}
|
||||
380
web/templates/iscsi.html
Normal file
380
web/templates/iscsi.html
Normal file
@@ -0,0 +1,380 @@
|
||||
{{define "iscsi-content"}}
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">iSCSI Targets</h1>
|
||||
<p class="text-slate-400">Manage iSCSI targets and LUNs</p>
|
||||
</div>
|
||||
<button onclick="showCreateISCSIModal()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium">
|
||||
Create Target
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">iSCSI Targets</h2>
|
||||
<button onclick="loadISCSITargets()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||
</div>
|
||||
<div id="iscsi-targets-list" class="p-4">
|
||||
<p class="text-slate-400 text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create iSCSI Target Modal -->
|
||||
<div id="create-iscsi-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-md w-full mx-4">
|
||||
<h3 class="text-xl font-semibold text-white mb-4">Create iSCSI Target</h3>
|
||||
<form id="create-iscsi-form" onsubmit="createISCSITarget(event)" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">IQN</label>
|
||||
<input type="text" name="iqn" placeholder="iqn.2024-12.com.atlas:target1" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
<p class="text-xs text-slate-400 mt-1">iSCSI Qualified Name</p>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" onclick="closeModal('create-iscsi-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add LUN Modal -->
|
||||
<div id="add-lun-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-md w-full mx-4">
|
||||
<h3 class="text-xl font-semibold text-white mb-4">Add LUN to Target</h3>
|
||||
<form id="add-lun-form" onsubmit="addLUN(event)" class="space-y-4">
|
||||
<input type="hidden" name="target_id" id="lun-target-id">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Select Storage Volume</label>
|
||||
<select name="zvol" id="lun-zvol-select" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
<option value="">Loading volumes...</option>
|
||||
</select>
|
||||
<p class="text-xs text-slate-400 mt-1">Or enter manually below</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Or Enter Volume Name Manually</label>
|
||||
<input type="text" name="zvol-manual" id="lun-zvol-manual" placeholder="pool/zvol" class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" onclick="closeModal('add-lun-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Add LUN
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function getAuthHeaders() {
|
||||
const token = localStorage.getItem('atlas_token');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes || bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
async function loadISCSITargets() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/iscsi/targets', { headers: getAuthHeaders() });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
||||
document.getElementById('iscsi-targets-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.error || 'Failed to load iSCSI targets'}</p>`;
|
||||
return;
|
||||
}
|
||||
const targets = await res.json();
|
||||
const listEl = document.getElementById('iscsi-targets-list');
|
||||
|
||||
if (!Array.isArray(targets)) {
|
||||
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid response format</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (targets.length === 0) {
|
||||
listEl.innerHTML = '<p class="text-slate-400 text-sm">No iSCSI targets found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = targets.map(target => `
|
||||
<div class="border-b border-slate-700 last:border-0 py-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h3 class="text-lg font-semibold text-white">${target.iqn}</h3>
|
||||
${target.enabled ? '<span class="px-2 py-1 rounded text-xs font-medium bg-green-900 text-green-300">Enabled</span>' : '<span class="px-2 py-1 rounded text-xs font-medium bg-slate-700 text-slate-300">Disabled</span>'}
|
||||
</div>
|
||||
<div class="text-sm text-slate-400 space-y-1 mb-3">
|
||||
<p>LUNs: ${target.luns ? target.luns.length : 0}</p>
|
||||
${target.initiators && target.initiators.length > 0 ? `<p>Initiators: ${target.initiators.join(', ')}</p>` : ''}
|
||||
</div>
|
||||
${target.luns && target.luns.length > 0 ? `
|
||||
<div class="bg-slate-900 rounded p-3 mt-2">
|
||||
<h4 class="text-sm font-medium text-slate-300 mb-2">LUNs (${target.luns.length}):</h4>
|
||||
<div class="space-y-2">
|
||||
${target.luns.map(lun => `
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-slate-400">LUN ${lun.id}:</span>
|
||||
<span class="text-slate-300 font-mono">${lun.zvol}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-slate-300">${formatBytes(lun.size)}</span>
|
||||
<button onclick="removeLUN('${target.id}', ${lun.id})" class="ml-2 px-2 py-1 bg-red-600 hover:bg-red-700 text-white rounded text-xs" title="Remove LUN">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : '<p class="text-sm text-slate-500 mt-2">No LUNs attached. Click "Add LUN" to bind a volume.</p>'}
|
||||
</div>
|
||||
<div class="flex gap-2 ml-4">
|
||||
<button onclick="showAddLUNModal('${target.id}')" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Add LUN
|
||||
</button>
|
||||
<button onclick="showConnectionInstructions('${target.id}')" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||
Connection Info
|
||||
</button>
|
||||
<button onclick="deleteISCSITarget('${target.id}')" class="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded text-sm">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (err) {
|
||||
document.getElementById('iscsi-targets-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function showCreateISCSIModal() {
|
||||
document.getElementById('create-iscsi-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function showAddLUNModal(targetId) {
|
||||
document.getElementById('lun-target-id').value = targetId;
|
||||
document.getElementById('add-lun-modal').classList.remove('hidden');
|
||||
|
||||
// Load available storage volumes
|
||||
await loadZVOLsForLUN();
|
||||
}
|
||||
|
||||
async function loadZVOLsForLUN() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/zvols', { headers: getAuthHeaders() });
|
||||
const selectEl = document.getElementById('lun-zvol-select');
|
||||
|
||||
if (!res.ok) {
|
||||
selectEl.innerHTML = '<option value="">Error loading volumes</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
const zvols = await res.json();
|
||||
|
||||
if (!Array.isArray(zvols)) {
|
||||
selectEl.innerHTML = '<option value="">No volumes available</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (zvols.length === 0) {
|
||||
selectEl.innerHTML = '<option value="">No volumes found. Create a storage volume first.</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear and populate dropdown
|
||||
selectEl.innerHTML = '<option value="">Select a volume...</option>';
|
||||
zvols.forEach(zvol => {
|
||||
const option = document.createElement('option');
|
||||
option.value = zvol.name;
|
||||
option.textContent = `${zvol.name} (${formatBytes(zvol.size)})`;
|
||||
selectEl.appendChild(option);
|
||||
});
|
||||
|
||||
// Update manual input when dropdown changes
|
||||
selectEl.addEventListener('change', function() {
|
||||
if (this.value) {
|
||||
document.getElementById('lun-zvol-manual').value = this.value;
|
||||
}
|
||||
});
|
||||
|
||||
// Update dropdown when manual input changes
|
||||
document.getElementById('lun-zvol-manual').addEventListener('input', function() {
|
||||
if (this.value && !selectEl.value) {
|
||||
// Allow manual entry even if not in dropdown
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error loading volumes:', err);
|
||||
document.getElementById('lun-zvol-select').innerHTML = '<option value="">Error loading volumes</option>';
|
||||
}
|
||||
}
|
||||
|
||||
async function showConnectionInstructions(targetId) {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/iscsi/targets/${targetId}/connection`, { headers: getAuthHeaders() });
|
||||
const data = await res.json();
|
||||
|
||||
const instructions = `
|
||||
Connection Instructions for ${data.target?.iqn || 'target'}:
|
||||
|
||||
Portal: ${data.portal || 'N/A'}
|
||||
|
||||
Linux:
|
||||
iscsiadm -m discovery -t st -p ${data.portal || '127.0.0.1'}
|
||||
iscsiadm -m node -T ${data.target?.iqn || ''} -p ${data.portal || '127.0.0.1'} --login
|
||||
|
||||
Windows:
|
||||
Use iSCSI Initiator from Control Panel
|
||||
Add target: ${data.portal || '127.0.0.1'}:${data.port || 3260}
|
||||
Target: ${data.target?.iqn || ''}
|
||||
|
||||
macOS:
|
||||
Use System Preferences > Network > iSCSI
|
||||
`;
|
||||
|
||||
alert(instructions);
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal(modalId) {
|
||||
document.getElementById(modalId).classList.add('hidden');
|
||||
}
|
||||
|
||||
async function createISCSITarget(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/iscsi/targets', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
iqn: formData.get('iqn')
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeModal('create-iscsi-modal');
|
||||
e.target.reset();
|
||||
loadISCSITargets();
|
||||
alert('iSCSI target created successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to create iSCSI target'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function addLUN(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const targetId = formData.get('target_id');
|
||||
|
||||
// Get volume from either dropdown or manual input
|
||||
const zvolSelect = document.getElementById('lun-zvol-select').value;
|
||||
const zvolManual = document.getElementById('lun-zvol-manual').value.trim();
|
||||
const zvol = zvolSelect || zvolManual;
|
||||
|
||||
if (!zvol) {
|
||||
alert('Please select or enter a volume name');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/iscsi/targets/${targetId}/luns`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
zvol: zvol
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeModal('add-lun-modal');
|
||||
e.target.reset();
|
||||
document.getElementById('lun-zvol-select').innerHTML = '<option value="">Loading volumes...</option>';
|
||||
document.getElementById('lun-zvol-manual').value = '';
|
||||
loadISCSITargets();
|
||||
alert('LUN added successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to add LUN'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeLUN(targetId, lunId) {
|
||||
if (!confirm(`Are you sure you want to remove LUN ${lunId} from this target?`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/iscsi/targets/${targetId}/luns/remove`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
lun_id: lunId
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
loadISCSITargets();
|
||||
alert('LUN removed successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to remove LUN'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteISCSITarget(id) {
|
||||
if (!confirm('Are you sure you want to delete this iSCSI target? All LUNs will be removed.')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/iscsi/targets/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
loadISCSITargets();
|
||||
alert('iSCSI target deleted successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to delete iSCSI target'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Load initial data
|
||||
loadISCSITargets();
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{define "iscsi.html"}}
|
||||
{{template "base" .}}
|
||||
{{end}}
|
||||
81
web/templates/login.html
Normal file
81
web/templates/login.html
Normal file
@@ -0,0 +1,81 @@
|
||||
{{define "login-content"}}
|
||||
<div class="min-h-screen flex items-center justify-center bg-slate-950">
|
||||
<div class="max-w-md w-full mx-4">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 p-8 shadow-xl">
|
||||
<div class="text-center mb-8">
|
||||
<div class="h-16 w-16 rounded-lg bg-slate-700 flex items-center justify-center font-bold text-2xl mx-auto mb-4">A</div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">AtlasOS</h1>
|
||||
<p class="text-slate-400">Storage Controller</p>
|
||||
</div>
|
||||
|
||||
<form id="login-form" onsubmit="handleLogin(event)" class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">Username</label>
|
||||
<input type="text" name="username" id="username" required autofocus class="w-full px-4 py-3 bg-slate-900 border border-slate-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-600" placeholder="Enter your username">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">Password</label>
|
||||
<input type="password" name="password" id="password" required class="w-full px-4 py-3 bg-slate-900 border border-slate-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-600" placeholder="Enter your password">
|
||||
</div>
|
||||
<div id="login-error" class="hidden text-red-400 text-sm"></div>
|
||||
<button type="submit" class="w-full px-4 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center text-sm text-slate-400">
|
||||
<p>Default credentials: <span class="font-mono text-slate-300">admin / admin</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
async function handleLogin(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const errorEl = document.getElementById('login-error');
|
||||
|
||||
errorEl.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: formData.get('username'),
|
||||
password: formData.get('password')
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => null);
|
||||
|
||||
if (res.ok && data.token) {
|
||||
// Store token in localStorage
|
||||
localStorage.setItem('atlas_token', data.token);
|
||||
|
||||
// Store user info if available
|
||||
if (data.user) {
|
||||
localStorage.setItem('atlas_user', JSON.stringify(data.user));
|
||||
}
|
||||
|
||||
// Redirect to dashboard or return URL
|
||||
const returnUrl = new URLSearchParams(window.location.search).get('return') || '/';
|
||||
window.location.href = returnUrl;
|
||||
} else {
|
||||
const errorMsg = (data && data.error) ? data.error : 'Login failed';
|
||||
errorEl.textContent = errorMsg;
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
} catch (err) {
|
||||
errorEl.textContent = `Error: ${err.message}`;
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{define "login.html"}}
|
||||
{{template "base" .}}
|
||||
{{end}}
|
||||
710
web/templates/management.html
Normal file
710
web/templates/management.html
Normal file
@@ -0,0 +1,710 @@
|
||||
{{define "management-content"}}
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">System Management</h1>
|
||||
<p class="text-slate-400">Manage services and users</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="border-b border-slate-800">
|
||||
<nav class="flex gap-4">
|
||||
<button onclick="switchTab('services')" id="tab-services" class="tab-button px-4 py-2 border-b-2 border-blue-600 text-blue-400 font-medium">
|
||||
Services
|
||||
</button>
|
||||
<button onclick="switchTab('users')" id="tab-users" class="tab-button px-4 py-2 border-b-2 border-transparent text-slate-400 hover:text-slate-300">
|
||||
Users
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Services Tab -->
|
||||
<div id="content-services" class="tab-content">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">Service Management</h2>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="loadServiceStatus()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 space-y-4">
|
||||
<!-- Services List -->
|
||||
<div id="services-list" class="space-y-4">
|
||||
<p class="text-slate-400 text-sm">Loading services...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users Tab -->
|
||||
<div id="content-users" class="tab-content hidden">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">User Management</h2>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="showCreateUserModal()" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Create User
|
||||
</button>
|
||||
<button onclick="loadUsers()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="users-list" class="p-4">
|
||||
<p class="text-slate-400 text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Service Logs Modal -->
|
||||
<div id="service-logs-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-4xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-xl font-semibold text-white" id="service-logs-title">Service Logs</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="number" id="logs-lines" value="50" min="10" max="1000" class="w-20 px-2 py-1 bg-slate-900 border border-slate-700 rounded text-white text-sm">
|
||||
<button onclick="loadServiceLogs()" class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">Refresh</button>
|
||||
<button onclick="closeModal('service-logs-modal')" class="px-3 py-1 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="service-logs-content" class="flex-1 overflow-y-auto bg-slate-900 rounded p-4 text-sm text-slate-300 font-mono whitespace-pre-wrap">
|
||||
Loading logs...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create User Modal -->
|
||||
<div id="create-user-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-md w-full mx-4">
|
||||
<h3 class="text-xl font-semibold text-white mb-4">Create User</h3>
|
||||
<form id="create-user-form" onsubmit="createUser(event)" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Username *</label>
|
||||
<input type="text" name="username" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Email</label>
|
||||
<input type="email" name="email" class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Password *</label>
|
||||
<input type="password" name="password" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Role *</label>
|
||||
<select name="role" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="operator">Operator</option>
|
||||
<option value="administrator">Administrator</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" onclick="closeModal('create-user-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit User Modal -->
|
||||
<div id="edit-user-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-md w-full mx-4">
|
||||
<h3 class="text-xl font-semibold text-white mb-4">Edit User</h3>
|
||||
<form id="edit-user-form" onsubmit="updateUser(event)" class="space-y-4">
|
||||
<input type="hidden" id="edit-user-id" name="id">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Username</label>
|
||||
<input type="text" id="edit-username" disabled class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-slate-500 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Email</label>
|
||||
<input type="email" id="edit-email" name="email" class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Role</label>
|
||||
<select id="edit-role" name="role" class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="operator">Operator</option>
|
||||
<option value="administrator">Administrator</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-300">
|
||||
<input type="checkbox" id="edit-active" name="active" class="rounded bg-slate-900 border-slate-700">
|
||||
<span>Active</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" onclick="closeModal('edit-user-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change Password Modal -->
|
||||
<div id="change-password-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-md w-full mx-4">
|
||||
<h3 class="text-xl font-semibold text-white mb-4">Change Password</h3>
|
||||
<form id="change-password-form" onsubmit="changePassword(event)" class="space-y-4">
|
||||
<input type="hidden" id="change-password-user-id" name="id">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Current Password *</label>
|
||||
<input type="password" name="old_password" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">New Password *</label>
|
||||
<input type="password" name="new_password" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Confirm New Password *</label>
|
||||
<input type="password" name="confirm_password" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" onclick="closeModal('change-password-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Change Password
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function getAuthHeaders() {
|
||||
const token = localStorage.getItem('atlas_token');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function switchTab(tab) {
|
||||
// Hide all tabs
|
||||
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
|
||||
document.querySelectorAll('.tab-button').forEach(el => {
|
||||
el.classList.remove('border-blue-600', 'text-blue-400', 'font-medium');
|
||||
el.classList.add('border-transparent', 'text-slate-400');
|
||||
});
|
||||
|
||||
// Show selected tab
|
||||
document.getElementById(`content-${tab}`).classList.remove('hidden');
|
||||
document.getElementById(`tab-${tab}`).classList.remove('border-transparent', 'text-slate-400');
|
||||
document.getElementById(`tab-${tab}`).classList.add('border-blue-600', 'text-blue-400', 'font-medium');
|
||||
|
||||
// Load data for the tab
|
||||
if (tab === 'services') loadServiceStatus();
|
||||
else if (tab === 'users') loadUsers();
|
||||
}
|
||||
|
||||
function closeModal(modalId) {
|
||||
document.getElementById(modalId).classList.add('hidden');
|
||||
}
|
||||
|
||||
// ===== Service Management =====
|
||||
|
||||
let currentServiceName = null;
|
||||
|
||||
async function loadServiceStatus() {
|
||||
const token = localStorage.getItem('atlas_token');
|
||||
const listEl = document.getElementById('services-list');
|
||||
|
||||
if (!token) {
|
||||
listEl.innerHTML = `
|
||||
<div class="text-center py-4">
|
||||
<p class="text-slate-400 mb-2">Authentication required</p>
|
||||
<a href="/login?return=/management" class="text-blue-400 hover:text-blue-300">Click here to login</a>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = '<p class="text-slate-400 text-sm">Loading services...</p>';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/services', { headers: getAuthHeaders() });
|
||||
const data = await res.json().catch(() => null);
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
localStorage.removeItem('atlas_token');
|
||||
localStorage.removeItem('atlas_user');
|
||||
listEl.innerHTML = `
|
||||
<div class="text-center py-4">
|
||||
<p class="text-slate-400 mb-2">Session expired. Please login again.</p>
|
||||
<a href="/login?return=/management" class="text-blue-400 hover:text-blue-300">Click here to login</a>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
const errorMsg = (data && data.error) ? data.error : `HTTP ${res.status}`;
|
||||
listEl.innerHTML = `<p class="text-red-400 text-sm">Error: ${errorMsg}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const services = (data && data.services) ? data.services : [];
|
||||
|
||||
if (services.length === 0) {
|
||||
listEl.innerHTML = '<p class="text-slate-400 text-sm">No services found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Render all services
|
||||
listEl.innerHTML = services.map(svc => {
|
||||
const statusBadge = getStatusBadge(svc.status);
|
||||
const statusColor = getStatusColor(svc.status);
|
||||
|
||||
return `
|
||||
<div class="bg-slate-900 rounded-lg border border-slate-700 p-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-md font-semibold text-white">${escapeHtml(svc.display_name)}</h3>
|
||||
<div class="px-3 py-1 rounded text-xs font-medium ${statusColor}">
|
||||
${statusBadge}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-slate-500 font-mono mb-4 max-h-24 overflow-y-auto whitespace-pre-wrap">
|
||||
${escapeHtml((svc.output || '').split('\n').slice(-5).join('\n')) || 'No status information'}
|
||||
</div>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<button onclick="serviceAction('${svc.name}', 'start')" class="px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white rounded text-xs">
|
||||
Start
|
||||
</button>
|
||||
<button onclick="serviceAction('${svc.name}', 'stop')" class="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded text-xs">
|
||||
Stop
|
||||
</button>
|
||||
<button onclick="serviceAction('${svc.name}', 'restart')" class="px-3 py-1.5 bg-yellow-600 hover:bg-yellow-700 text-white rounded text-xs">
|
||||
Restart
|
||||
</button>
|
||||
<button onclick="serviceAction('${svc.name}', 'reload')" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-xs">
|
||||
Reload
|
||||
</button>
|
||||
<button onclick="showServiceLogs('${svc.name}', '${escapeHtml(svc.display_name)}')" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white rounded text-xs">
|
||||
View Logs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
listEl.innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusBadge(status) {
|
||||
switch(status) {
|
||||
case 'running': return 'Running';
|
||||
case 'stopped': return 'Stopped';
|
||||
case 'failed': return 'Failed';
|
||||
case 'not-found': return 'Not Found';
|
||||
default: return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusColor(status) {
|
||||
switch(status) {
|
||||
case 'running': return 'bg-green-900 text-green-300';
|
||||
case 'stopped': return 'bg-red-900 text-red-300';
|
||||
case 'failed': return 'bg-red-900 text-red-300';
|
||||
case 'not-found': return 'bg-slate-700 text-slate-300';
|
||||
default: return 'bg-slate-700 text-slate-300';
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
async function serviceAction(serviceName, action) {
|
||||
const token = localStorage.getItem('atlas_token');
|
||||
if (!token) {
|
||||
alert('Please login first');
|
||||
window.location.href = '/login?return=/management';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to ${action} ${serviceName}?`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/services/${action}?service=${encodeURIComponent(serviceName)}`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => null);
|
||||
|
||||
if (res.ok) {
|
||||
alert(`${serviceName} ${action}ed successfully`);
|
||||
loadServiceStatus();
|
||||
} else {
|
||||
const errorMsg = (data && data.error) ? data.error : `Failed to ${action} service`;
|
||||
alert(`Error: ${errorMsg}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function showServiceLogs(serviceName, displayName) {
|
||||
const token = localStorage.getItem('atlas_token');
|
||||
if (!token) {
|
||||
alert('Please login first');
|
||||
window.location.href = '/login?return=/management';
|
||||
return;
|
||||
}
|
||||
|
||||
currentServiceName = serviceName;
|
||||
document.getElementById('service-logs-title').textContent = `${displayName} Logs`;
|
||||
document.getElementById('service-logs-modal').classList.remove('hidden');
|
||||
loadServiceLogs();
|
||||
}
|
||||
|
||||
async function loadServiceLogs() {
|
||||
const token = localStorage.getItem('atlas_token');
|
||||
if (!token) {
|
||||
document.getElementById('service-logs-content').textContent = 'Authentication required';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentServiceName) {
|
||||
document.getElementById('service-logs-content').textContent = 'No service selected';
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = document.getElementById('logs-lines').value || '50';
|
||||
try {
|
||||
const res = await fetch(`/api/v1/services/logs?service=${encodeURIComponent(currentServiceName)}&lines=${lines}`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMsg = (data && data.error) ? data.error : `HTTP ${res.status}`;
|
||||
document.getElementById('service-logs-content').textContent = `Error: ${errorMsg}`;
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('service-logs-content').textContent = data.logs || 'No logs available';
|
||||
} catch (err) {
|
||||
document.getElementById('service-logs-content').textContent = `Error: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== User Management =====
|
||||
|
||||
async function loadUsers(forceRefresh = false) {
|
||||
const token = localStorage.getItem('atlas_token');
|
||||
const listEl = document.getElementById('users-list');
|
||||
|
||||
if (!token) {
|
||||
listEl.innerHTML = `
|
||||
<div class="text-center py-8">
|
||||
<p class="text-slate-400 mb-2">Authentication required to view users</p>
|
||||
<a href="/login?return=/management" class="text-blue-400 hover:text-blue-300">Click here to login</a>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
listEl.innerHTML = '<p class="text-slate-400 text-sm">Loading users...</p>';
|
||||
|
||||
try {
|
||||
// Add cache busting parameter if force refresh
|
||||
const url = forceRefresh ? `/api/v1/users?_t=${Date.now()}` : '/api/v1/users';
|
||||
const res = await fetch(url, {
|
||||
headers: getAuthHeaders(),
|
||||
cache: forceRefresh ? 'no-cache' : 'default'
|
||||
});
|
||||
|
||||
const rawText = await res.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = JSON.parse(rawText);
|
||||
} catch (parseErr) {
|
||||
console.error('Failed to parse JSON response:', parseErr, 'Raw response:', rawText);
|
||||
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid JSON response from server</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
localStorage.removeItem('atlas_token');
|
||||
localStorage.removeItem('atlas_user');
|
||||
listEl.innerHTML = `
|
||||
<div class="text-center py-8">
|
||||
<p class="text-slate-400 mb-2">Session expired. Please login again.</p>
|
||||
<a href="/login?return=/management" class="text-blue-400 hover:text-blue-300">Click here to login</a>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
const errorMsg = (data && data.error) ? data.error : `HTTP ${res.status}`;
|
||||
listEl.innerHTML = `<p class="text-red-400 text-sm">Error: ${errorMsg}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data || !Array.isArray(data)) {
|
||||
console.error('Invalid response format:', data);
|
||||
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid response format (expected array)</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Loaded ${data.length} users:`, data);
|
||||
|
||||
if (data.length === 0) {
|
||||
listEl.innerHTML = '<p class="text-slate-400 text-sm">No users found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort users by ID to ensure consistent ordering
|
||||
const sortedUsers = data.sort((a, b) => {
|
||||
const idA = (a.id || '').toLowerCase();
|
||||
const idB = (b.id || '').toLowerCase();
|
||||
return idA.localeCompare(idB);
|
||||
});
|
||||
|
||||
listEl.innerHTML = sortedUsers.map(user => {
|
||||
// Escape HTML to prevent XSS
|
||||
const escapeHtml = (text) => {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
};
|
||||
|
||||
const username = escapeHtml(user.username || 'N/A');
|
||||
const email = user.email ? escapeHtml(user.email) : '';
|
||||
const userId = escapeHtml(user.id || 'N/A');
|
||||
const role = escapeHtml(user.role || 'N/A');
|
||||
const active = user.active !== false;
|
||||
|
||||
return `
|
||||
<div class="border-b border-slate-700 last:border-0 py-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h3 class="text-lg font-semibold text-white">${username}</h3>
|
||||
${active ? '<span class="px-2 py-1 rounded text-xs font-medium bg-green-900 text-green-300">Active</span>' : '<span class="px-2 py-1 rounded text-xs font-medium bg-slate-700 text-slate-300">Inactive</span>'}
|
||||
<span class="px-2 py-1 rounded text-xs font-medium bg-blue-900 text-blue-300">${role}</span>
|
||||
</div>
|
||||
<div class="text-sm text-slate-400 space-y-1">
|
||||
${email ? `<p>Email: <span class="text-slate-300">${email}</span></p>` : ''}
|
||||
<p>ID: <span class="text-slate-300 font-mono text-xs">${userId}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 ml-4">
|
||||
<button onclick="showEditUserModal('${userId}')" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||
Edit
|
||||
</button>
|
||||
<button onclick="showChangePasswordModal('${userId}', '${username}')" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Change Password
|
||||
</button>
|
||||
<button onclick="deleteUser('${userId}', '${username}')" class="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded text-sm">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
document.getElementById('users-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function showCreateUserModal() {
|
||||
document.getElementById('create-user-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function createUser(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/users', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
username: formData.get('username'),
|
||||
email: formData.get('email') || '',
|
||||
password: formData.get('password'),
|
||||
role: formData.get('role')
|
||||
})
|
||||
});
|
||||
|
||||
const rawText = await res.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = JSON.parse(rawText);
|
||||
} catch (parseErr) {
|
||||
console.error('Failed to parse create user response:', parseErr, 'Raw:', rawText);
|
||||
alert('Error: Invalid response from server');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Create user response:', res.status, data);
|
||||
|
||||
if (res.ok || res.status === 201) {
|
||||
console.log('User created successfully, refreshing list...');
|
||||
closeModal('create-user-modal');
|
||||
e.target.reset();
|
||||
// Force reload users list - add cache busting
|
||||
await loadUsers(true);
|
||||
alert('User created successfully');
|
||||
} else {
|
||||
const errorMsg = (data && data.error) ? data.error : 'Failed to create user';
|
||||
console.error('Create user failed:', errorMsg);
|
||||
alert(`Error: ${errorMsg}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function showEditUserModal(userId) {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/users/${userId}`, { headers: getAuthHeaders() });
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to load user'}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await res.json();
|
||||
document.getElementById('edit-user-id').value = user.id;
|
||||
document.getElementById('edit-username').value = user.username || '';
|
||||
document.getElementById('edit-email').value = user.email || '';
|
||||
document.getElementById('edit-role').value = user.role || 'Viewer';
|
||||
document.getElementById('edit-active').checked = user.active !== false;
|
||||
|
||||
document.getElementById('edit-user-modal').classList.remove('hidden');
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateUser(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const userId = formData.get('id');
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/users/${userId}`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
email: formData.get('email') || '',
|
||||
role: formData.get('role'),
|
||||
active: formData.get('active') === 'on'
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeModal('edit-user-modal');
|
||||
await loadUsers(true);
|
||||
alert('User updated successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to update user'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(userId, username) {
|
||||
if (!confirm(`Are you sure you want to delete user "${username}"? This action cannot be undone.`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/users/${userId}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
await loadUsers(true);
|
||||
alert('User deleted successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to delete user'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function showChangePasswordModal(userId, username) {
|
||||
document.getElementById('change-password-user-id').value = userId;
|
||||
document.getElementById('change-password-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function changePassword(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const userId = document.getElementById('change-password-user-id').value;
|
||||
const newPassword = formData.get('new_password');
|
||||
const confirmPassword = formData.get('confirm_password');
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
alert('New passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/users/${userId}/password`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
old_password: formData.get('old_password'),
|
||||
new_password: newPassword
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeModal('change-password-modal');
|
||||
e.target.reset();
|
||||
alert('Password changed successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to change password'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Load initial data when page loads
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadServiceStatus();
|
||||
// Load users if on users tab
|
||||
const usersTab = document.getElementById('tab-users');
|
||||
if (usersTab && usersTab.classList.contains('border-blue-600')) {
|
||||
loadUsers();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
loadServiceStatus();
|
||||
// Load users if on users tab
|
||||
const usersTab = document.getElementById('tab-users');
|
||||
if (usersTab && usersTab.classList.contains('border-blue-600')) {
|
||||
loadUsers();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{define "management.html"}}
|
||||
{{template "base" .}}
|
||||
{{end}}
|
||||
704
web/templates/protection.html
Normal file
704
web/templates/protection.html
Normal file
@@ -0,0 +1,704 @@
|
||||
{{define "protection-content"}}
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Data Protection</h1>
|
||||
<p class="text-slate-400">Manage snapshots, scheduling, and replication</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="border-b border-slate-800">
|
||||
<nav class="flex gap-4">
|
||||
<button onclick="switchTab('snapshots')" id="tab-snapshots" class="tab-button px-4 py-2 border-b-2 border-blue-600 text-blue-400 font-medium">
|
||||
Snapshots
|
||||
</button>
|
||||
<button onclick="switchTab('policies')" id="tab-policies" class="tab-button px-4 py-2 border-b-2 border-transparent text-slate-400 hover:text-slate-300">
|
||||
Snapshot Policies
|
||||
</button>
|
||||
<button onclick="switchTab('volume-snapshots')" id="tab-volume-snapshots" class="tab-button px-4 py-2 border-b-2 border-transparent text-slate-400 hover:text-slate-300">
|
||||
Volume Snapshots
|
||||
</button>
|
||||
<button onclick="switchTab('replication')" id="tab-replication" class="tab-button px-4 py-2 border-b-2 border-transparent text-slate-400 hover:text-slate-300">
|
||||
Replication
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Snapshots Tab -->
|
||||
<div id="content-snapshots" class="tab-content">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">Snapshots</h2>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="showCreateSnapshotModal()" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Create Snapshot
|
||||
</button>
|
||||
<button onclick="loadSnapshots()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="snapshots-list" class="p-4">
|
||||
<p class="text-slate-400 text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Snapshot Policies Tab -->
|
||||
<div id="content-policies" class="tab-content hidden">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">Snapshot Policies</h2>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="showCreatePolicyModal()" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Create Policy
|
||||
</button>
|
||||
<button onclick="loadPolicies()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="policies-list" class="p-4">
|
||||
<p class="text-slate-400 text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Volume Snapshots Tab -->
|
||||
<div id="content-volume-snapshots" class="tab-content hidden">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">Volume Snapshots</h2>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="showCreateVolumeSnapshotModal()" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Create Volume Snapshot
|
||||
</button>
|
||||
<button onclick="loadVolumeSnapshots()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="volume-snapshots-list" class="p-4">
|
||||
<p class="text-slate-400 text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Replication Tab -->
|
||||
<div id="content-replication" class="tab-content hidden">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">Replication</h2>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="showCreateReplicationModal()" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Create Replication Task
|
||||
</button>
|
||||
<button onclick="loadReplications()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="replications-list" class="p-4">
|
||||
<div class="text-center py-8">
|
||||
<p class="text-slate-400 text-sm mb-2">Replication feature coming soon</p>
|
||||
<p class="text-slate-500 text-xs">This feature will allow you to replicate snapshots to remote systems</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Snapshot Modal -->
|
||||
<div id="create-snapshot-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-md w-full mx-4">
|
||||
<h3 class="text-xl font-semibold text-white mb-4">Create Snapshot</h3>
|
||||
<form id="create-snapshot-form" onsubmit="createSnapshot(event)" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Dataset</label>
|
||||
<select name="dataset" id="snapshot-dataset-select" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
<option value="">Loading datasets...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Snapshot Name</label>
|
||||
<input type="text" name="name" placeholder="snapshot-2024-12-15" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
</div>
|
||||
<div>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-300">
|
||||
<input type="checkbox" name="recursive" class="rounded bg-slate-900 border-slate-700">
|
||||
<span>Recursive (include child datasets)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" onclick="closeModal('create-snapshot-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Snapshot Policy Modal -->
|
||||
<div id="create-policy-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<h3 class="text-xl font-semibold text-white mb-4">Create Snapshot Policy</h3>
|
||||
<form id="create-policy-form" onsubmit="createPolicy(event)" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Dataset</label>
|
||||
<select name="dataset" id="policy-dataset-select" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
<option value="">Loading datasets...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Frequent (15min)</label>
|
||||
<input type="number" name="frequent" min="0" value="0" class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Hourly</label>
|
||||
<input type="number" name="hourly" min="0" value="0" class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Daily</label>
|
||||
<input type="number" name="daily" min="0" value="0" class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Weekly</label>
|
||||
<input type="number" name="weekly" min="0" value="0" class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Monthly</label>
|
||||
<input type="number" name="monthly" min="0" value="0" class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Yearly</label>
|
||||
<input type="number" name="yearly" min="0" value="0" class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 text-sm text-slate-300">
|
||||
<input type="checkbox" name="autosnap" checked class="rounded bg-slate-900 border-slate-700">
|
||||
<span>Enable automatic snapshots</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-300">
|
||||
<input type="checkbox" name="autoprune" checked class="rounded bg-slate-900 border-slate-700">
|
||||
<span>Enable automatic pruning</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" onclick="closeModal('create-policy-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Volume Snapshot Modal -->
|
||||
<div id="create-volume-snapshot-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-md w-full mx-4">
|
||||
<h3 class="text-xl font-semibold text-white mb-4">Create Volume Snapshot</h3>
|
||||
<form id="create-volume-snapshot-form" onsubmit="createVolumeSnapshot(event)" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Volume</label>
|
||||
<select name="volume" id="volume-snapshot-select" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
<option value="">Loading volumes...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Snapshot Name</label>
|
||||
<input type="text" name="name" placeholder="volume-snapshot-2024-12-15" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" onclick="closeModal('create-volume-snapshot-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function getAuthHeaders() {
|
||||
const token = localStorage.getItem('atlas_token');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes || bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return 'N/A';
|
||||
try {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
function switchTab(tab) {
|
||||
// Hide all tabs
|
||||
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
|
||||
document.querySelectorAll('.tab-button').forEach(el => {
|
||||
el.classList.remove('border-blue-600', 'text-blue-400', 'font-medium');
|
||||
el.classList.add('border-transparent', 'text-slate-400');
|
||||
});
|
||||
|
||||
// Show selected tab
|
||||
document.getElementById(`content-${tab}`).classList.remove('hidden');
|
||||
document.getElementById(`tab-${tab}`).classList.remove('border-transparent', 'text-slate-400');
|
||||
document.getElementById(`tab-${tab}`).classList.add('border-blue-600', 'text-blue-400', 'font-medium');
|
||||
|
||||
// Load data for the tab
|
||||
if (tab === 'snapshots') loadSnapshots();
|
||||
else if (tab === 'policies') loadPolicies();
|
||||
else if (tab === 'volume-snapshots') loadVolumeSnapshots();
|
||||
else if (tab === 'replication') loadReplications();
|
||||
}
|
||||
|
||||
function closeModal(modalId) {
|
||||
document.getElementById(modalId).classList.add('hidden');
|
||||
}
|
||||
|
||||
// Snapshot Management
|
||||
async function loadSnapshots() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/snapshots', { headers: getAuthHeaders() });
|
||||
const data = await res.json().catch(() => null);
|
||||
const listEl = document.getElementById('snapshots-list');
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMsg = (data && data.error) ? data.error : `HTTP ${res.status}`;
|
||||
listEl.innerHTML = `<p class="text-red-400 text-sm">Error: ${errorMsg}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data || !Array.isArray(data)) {
|
||||
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid response format</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
listEl.innerHTML = '<p class="text-slate-400 text-sm">No snapshots found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = data.map(snap => `
|
||||
<div class="border-b border-slate-700 last:border-0 py-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h3 class="text-lg font-semibold text-white font-mono">${snap.name}</h3>
|
||||
</div>
|
||||
<div class="text-sm text-slate-400 space-y-1">
|
||||
<p>Dataset: <span class="text-slate-300">${snap.dataset}</span></p>
|
||||
<p>Size: <span class="text-slate-300">${formatBytes(snap.size || 0)}</span></p>
|
||||
<p>Created: <span class="text-slate-300">${formatDate(snap.created_at)}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 ml-4">
|
||||
<button onclick="deleteSnapshot('${snap.name}')" class="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded text-sm">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (err) {
|
||||
document.getElementById('snapshots-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function showCreateSnapshotModal() {
|
||||
await loadDatasetsForSnapshot();
|
||||
document.getElementById('create-snapshot-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function loadDatasetsForSnapshot() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/datasets', { headers: getAuthHeaders() });
|
||||
const selectEl = document.getElementById('snapshot-dataset-select');
|
||||
|
||||
if (!res.ok) {
|
||||
selectEl.innerHTML = '<option value="">Error loading datasets</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
const datasets = await res.json();
|
||||
|
||||
if (!Array.isArray(datasets) || datasets.length === 0) {
|
||||
selectEl.innerHTML = '<option value="">No datasets found</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
selectEl.innerHTML = '<option value="">Select a dataset...</option>';
|
||||
datasets.forEach(ds => {
|
||||
const option = document.createElement('option');
|
||||
option.value = ds.name;
|
||||
option.textContent = `${ds.name} (${ds.type})`;
|
||||
selectEl.appendChild(option);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error loading datasets:', err);
|
||||
document.getElementById('snapshot-dataset-select').innerHTML = '<option value="">Error loading datasets</option>';
|
||||
}
|
||||
}
|
||||
|
||||
async function createSnapshot(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/snapshots', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
dataset: formData.get('dataset'),
|
||||
name: formData.get('name'),
|
||||
recursive: formData.get('recursive') === 'on'
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeModal('create-snapshot-modal');
|
||||
e.target.reset();
|
||||
loadSnapshots();
|
||||
alert('Snapshot created successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to create snapshot'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSnapshot(name) {
|
||||
if (!confirm(`Are you sure you want to delete snapshot "${name}"?`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/snapshots/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
loadSnapshots();
|
||||
alert('Snapshot deleted successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to delete snapshot'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot Policy Management
|
||||
async function loadPolicies() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/snapshot-policies', { headers: getAuthHeaders() });
|
||||
const data = await res.json().catch(() => null);
|
||||
const listEl = document.getElementById('policies-list');
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMsg = (data && data.error) ? data.error : `HTTP ${res.status}`;
|
||||
listEl.innerHTML = `<p class="text-red-400 text-sm">Error: ${errorMsg}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data || !Array.isArray(data)) {
|
||||
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid response format</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
listEl.innerHTML = '<p class="text-slate-400 text-sm">No snapshot policies found. Create a policy to enable automatic snapshots.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = data.map(policy => `
|
||||
<div class="border-b border-slate-700 last:border-0 py-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h3 class="text-lg font-semibold text-white">${policy.dataset}</h3>
|
||||
${policy.autosnap ? '<span class="px-2 py-1 rounded text-xs font-medium bg-green-900 text-green-300">Auto-snap Enabled</span>' : '<span class="px-2 py-1 rounded text-xs font-medium bg-slate-700 text-slate-300">Auto-snap Disabled</span>'}
|
||||
${policy.autoprune ? '<span class="px-2 py-1 rounded text-xs font-medium bg-blue-900 text-blue-300">Auto-prune Enabled</span>' : ''}
|
||||
</div>
|
||||
<div class="text-sm text-slate-400 space-y-1">
|
||||
<p>Retention:
|
||||
${policy.frequent > 0 ? `<span class="text-slate-300">${policy.frequent} frequent</span>` : ''}
|
||||
${policy.hourly > 0 ? `<span class="text-slate-300">${policy.hourly} hourly</span>` : ''}
|
||||
${policy.daily > 0 ? `<span class="text-slate-300">${policy.daily} daily</span>` : ''}
|
||||
${policy.weekly > 0 ? `<span class="text-slate-300">${policy.weekly} weekly</span>` : ''}
|
||||
${policy.monthly > 0 ? `<span class="text-slate-300">${policy.monthly} monthly</span>` : ''}
|
||||
${policy.yearly > 0 ? `<span class="text-slate-300">${policy.yearly} yearly</span>` : ''}
|
||||
${!policy.frequent && !policy.hourly && !policy.daily && !policy.weekly && !policy.monthly && !policy.yearly ? '<span class="text-slate-500">No retention configured</span>' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 ml-4">
|
||||
<button onclick="editPolicy('${policy.dataset}')" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||
Edit
|
||||
</button>
|
||||
<button onclick="deletePolicy('${policy.dataset}')" class="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded text-sm">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (err) {
|
||||
document.getElementById('policies-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function showCreatePolicyModal() {
|
||||
await loadDatasetsForPolicy();
|
||||
document.getElementById('create-policy-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function loadDatasetsForPolicy() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/datasets', { headers: getAuthHeaders() });
|
||||
const selectEl = document.getElementById('policy-dataset-select');
|
||||
|
||||
if (!res.ok) {
|
||||
selectEl.innerHTML = '<option value="">Error loading datasets</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
const datasets = await res.json();
|
||||
|
||||
if (!Array.isArray(datasets) || datasets.length === 0) {
|
||||
selectEl.innerHTML = '<option value="">No datasets found</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
selectEl.innerHTML = '<option value="">Select a dataset...</option>';
|
||||
datasets.forEach(ds => {
|
||||
const option = document.createElement('option');
|
||||
option.value = ds.name;
|
||||
option.textContent = `${ds.name} (${ds.type})`;
|
||||
selectEl.appendChild(option);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error loading datasets:', err);
|
||||
document.getElementById('policy-dataset-select').innerHTML = '<option value="">Error loading datasets</option>';
|
||||
}
|
||||
}
|
||||
|
||||
async function createPolicy(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/snapshot-policies', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
dataset: formData.get('dataset'),
|
||||
frequent: parseInt(formData.get('frequent')) || 0,
|
||||
hourly: parseInt(formData.get('hourly')) || 0,
|
||||
daily: parseInt(formData.get('daily')) || 0,
|
||||
weekly: parseInt(formData.get('weekly')) || 0,
|
||||
monthly: parseInt(formData.get('monthly')) || 0,
|
||||
yearly: parseInt(formData.get('yearly')) || 0,
|
||||
autosnap: formData.get('autosnap') === 'on',
|
||||
autoprune: formData.get('autoprune') === 'on'
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeModal('create-policy-modal');
|
||||
e.target.reset();
|
||||
loadPolicies();
|
||||
alert('Snapshot policy created successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to create snapshot policy'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function editPolicy(dataset) {
|
||||
// Load policy and populate form (similar to create but with PUT)
|
||||
alert('Edit policy feature - coming soon');
|
||||
}
|
||||
|
||||
async function deletePolicy(dataset) {
|
||||
if (!confirm(`Are you sure you want to delete snapshot policy for "${dataset}"?`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/snapshot-policies/${encodeURIComponent(dataset)}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
loadPolicies();
|
||||
alert('Snapshot policy deleted successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to delete snapshot policy'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Volume Snapshots (same as regular snapshots but filtered for volumes)
|
||||
async function loadVolumeSnapshots() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/snapshots', { headers: getAuthHeaders() });
|
||||
const data = await res.json().catch(() => null);
|
||||
const listEl = document.getElementById('volume-snapshots-list');
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMsg = (data && data.error) ? data.error : `HTTP ${res.status}`;
|
||||
listEl.innerHTML = `<p class="text-red-400 text-sm">Error: ${errorMsg}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data || !Array.isArray(data)) {
|
||||
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid response format</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter for volume snapshots (ZVOLs)
|
||||
const volumeSnapshots = data.filter(snap => {
|
||||
// Check if dataset is a volume (starts with pool/ and might be a zvol)
|
||||
return snap.dataset && snap.dataset.includes('/');
|
||||
});
|
||||
|
||||
if (volumeSnapshots.length === 0) {
|
||||
listEl.innerHTML = '<p class="text-slate-400 text-sm">No volume snapshots found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = volumeSnapshots.map(snap => `
|
||||
<div class="border-b border-slate-700 last:border-0 py-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h3 class="text-lg font-semibold text-white font-mono">${snap.name}</h3>
|
||||
</div>
|
||||
<div class="text-sm text-slate-400 space-y-1">
|
||||
<p>Volume: <span class="text-slate-300">${snap.dataset}</span></p>
|
||||
<p>Size: <span class="text-slate-300">${formatBytes(snap.size || 0)}</span></p>
|
||||
<p>Created: <span class="text-slate-300">${formatDate(snap.created_at)}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 ml-4">
|
||||
<button onclick="deleteSnapshot('${snap.name}')" class="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded text-sm">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (err) {
|
||||
document.getElementById('volume-snapshots-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function showCreateVolumeSnapshotModal() {
|
||||
await loadVolumesForSnapshot();
|
||||
document.getElementById('create-volume-snapshot-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function loadVolumesForSnapshot() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/zvols', { headers: getAuthHeaders() });
|
||||
const selectEl = document.getElementById('volume-snapshot-select');
|
||||
|
||||
if (!res.ok) {
|
||||
selectEl.innerHTML = '<option value="">Error loading volumes</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
const volumes = await res.json();
|
||||
|
||||
if (!Array.isArray(volumes) || volumes.length === 0) {
|
||||
selectEl.innerHTML = '<option value="">No volumes found</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
selectEl.innerHTML = '<option value="">Select a volume...</option>';
|
||||
volumes.forEach(vol => {
|
||||
const option = document.createElement('option');
|
||||
option.value = vol.name;
|
||||
option.textContent = `${vol.name} (${formatBytes(vol.size)})`;
|
||||
selectEl.appendChild(option);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error loading volumes:', err);
|
||||
document.getElementById('volume-snapshot-select').innerHTML = '<option value="">Error loading volumes</option>';
|
||||
}
|
||||
}
|
||||
|
||||
async function createVolumeSnapshot(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/snapshots', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
dataset: formData.get('volume'),
|
||||
name: formData.get('name'),
|
||||
recursive: false
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeModal('create-volume-snapshot-modal');
|
||||
e.target.reset();
|
||||
loadVolumeSnapshots();
|
||||
alert('Volume snapshot created successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to create volume snapshot'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Replication (placeholder)
|
||||
async function loadReplications() {
|
||||
// Placeholder - replication not yet implemented
|
||||
document.getElementById('replications-list').innerHTML = `
|
||||
<div class="text-center py-8">
|
||||
<p class="text-slate-400 text-sm mb-2">Replication feature coming soon</p>
|
||||
<p class="text-slate-500 text-xs">This feature will allow you to replicate snapshots to remote systems</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function showCreateReplicationModal() {
|
||||
alert('Replication feature coming soon');
|
||||
}
|
||||
|
||||
// Load initial data
|
||||
loadSnapshots();
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{define "protection.html"}}
|
||||
{{template "base" .}}
|
||||
{{end}}
|
||||
379
web/templates/shares.html
Normal file
379
web/templates/shares.html
Normal file
@@ -0,0 +1,379 @@
|
||||
{{define "shares-content"}}
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Storage Shares</h1>
|
||||
<p class="text-slate-400">Manage SMB and NFS shares</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="border-b border-slate-800">
|
||||
<nav class="flex gap-4">
|
||||
<button onclick="switchTab('smb')" id="tab-smb" class="tab-button px-4 py-2 border-b-2 border-blue-600 text-blue-400 font-medium">
|
||||
SMB Shares
|
||||
</button>
|
||||
<button onclick="switchTab('nfs')" id="tab-nfs" class="tab-button px-4 py-2 border-b-2 border-transparent text-slate-400 hover:text-slate-300">
|
||||
NFS Exports
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- SMB Shares Tab -->
|
||||
<div id="content-smb" class="tab-content">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">SMB/CIFS Shares</h2>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="showCreateSMBModal()" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Create Share
|
||||
</button>
|
||||
<button onclick="loadSMBShares()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="smb-shares-list" class="p-4">
|
||||
<p class="text-slate-400 text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NFS Exports Tab -->
|
||||
<div id="content-nfs" class="tab-content hidden">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">NFS Exports</h2>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="showCreateNFSModal()" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Create Export
|
||||
</button>
|
||||
<button onclick="loadNFSExports()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="nfs-exports-list" class="p-4">
|
||||
<p class="text-slate-400 text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create SMB Share Modal -->
|
||||
<div id="create-smb-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-md w-full mx-4">
|
||||
<h3 class="text-xl font-semibold text-white mb-4">Create SMB Share</h3>
|
||||
<form id="create-smb-form" onsubmit="createSMBShare(event)" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Share Name</label>
|
||||
<input type="text" name="name" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Dataset</label>
|
||||
<input type="text" name="dataset" placeholder="pool/dataset" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Description (optional)</label>
|
||||
<input type="text" name="description" class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" name="readonly" id="smb-readonly" class="w-4 h-4 text-blue-600 bg-slate-900 border-slate-700 rounded">
|
||||
<label for="smb-readonly" class="text-sm text-slate-300">Read-only</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" name="guest_ok" id="smb-guest" class="w-4 h-4 text-blue-600 bg-slate-900 border-slate-700 rounded">
|
||||
<label for="smb-guest" class="text-sm text-slate-300">Allow guest access</label>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" onclick="closeModal('create-smb-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create NFS Export Modal -->
|
||||
<div id="create-nfs-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-md w-full mx-4">
|
||||
<h3 class="text-xl font-semibold text-white mb-4">Create NFS Export</h3>
|
||||
<form id="create-nfs-form" onsubmit="createNFSExport(event)" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Dataset</label>
|
||||
<input type="text" name="dataset" placeholder="pool/dataset" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Clients (comma-separated)</label>
|
||||
<input type="text" name="clients" placeholder="192.168.1.0/24,*" class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
<p class="text-xs text-slate-400 mt-1">Leave empty or use * for all clients</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" name="readonly" id="nfs-readonly" class="w-4 h-4 text-blue-600 bg-slate-900 border-slate-700 rounded">
|
||||
<label for="nfs-readonly" class="text-sm text-slate-300">Read-only</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" name="root_squash" id="nfs-rootsquash" class="w-4 h-4 text-blue-600 bg-slate-900 border-slate-700 rounded" checked>
|
||||
<label for="nfs-rootsquash" class="text-sm text-slate-300">Root squash</label>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" onclick="closeModal('create-nfs-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentTab = 'smb';
|
||||
|
||||
function switchTab(tab) {
|
||||
currentTab = tab;
|
||||
document.querySelectorAll('.tab-button').forEach(btn => {
|
||||
btn.classList.remove('border-blue-600', 'text-blue-400');
|
||||
btn.classList.add('border-transparent', 'text-slate-400');
|
||||
});
|
||||
document.getElementById(`tab-${tab}`).classList.remove('border-transparent', 'text-slate-400');
|
||||
document.getElementById(`tab-${tab}`).classList.add('border-blue-600', 'text-blue-400');
|
||||
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.add('hidden');
|
||||
});
|
||||
document.getElementById(`content-${tab}`).classList.remove('hidden');
|
||||
|
||||
if (tab === 'smb') loadSMBShares();
|
||||
else if (tab === 'nfs') loadNFSExports();
|
||||
}
|
||||
|
||||
function getAuthHeaders() {
|
||||
const token = localStorage.getItem('atlas_token');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||
};
|
||||
}
|
||||
|
||||
async function loadSMBShares() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/shares/smb', { headers: getAuthHeaders() });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
||||
document.getElementById('smb-shares-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.error || 'Failed to load SMB shares'}</p>`;
|
||||
return;
|
||||
}
|
||||
const shares = await res.json();
|
||||
const listEl = document.getElementById('smb-shares-list');
|
||||
|
||||
if (!Array.isArray(shares)) {
|
||||
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid response format</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (shares.length === 0) {
|
||||
listEl.innerHTML = '<p class="text-slate-400 text-sm">No SMB shares found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = shares.map(share => `
|
||||
<div class="border-b border-slate-700 last:border-0 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h3 class="text-lg font-semibold text-white">${share.name}</h3>
|
||||
${share.enabled ? '<span class="px-2 py-1 rounded text-xs font-medium bg-green-900 text-green-300">Enabled</span>' : '<span class="px-2 py-1 rounded text-xs font-medium bg-slate-700 text-slate-300">Disabled</span>'}
|
||||
</div>
|
||||
<div class="text-sm text-slate-400 space-y-1">
|
||||
<p>Path: ${share.path || 'N/A'}</p>
|
||||
<p>Dataset: ${share.dataset || 'N/A'}</p>
|
||||
${share.description ? `<p>Description: ${share.description}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="deleteSMBShare('${share.id}')" class="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded text-sm">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (err) {
|
||||
document.getElementById('smb-shares-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNFSExports() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/exports/nfs', { headers: getAuthHeaders() });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
||||
document.getElementById('nfs-exports-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.error || 'Failed to load NFS exports'}</p>`;
|
||||
return;
|
||||
}
|
||||
const exports = await res.json();
|
||||
const listEl = document.getElementById('nfs-exports-list');
|
||||
|
||||
if (!Array.isArray(exports)) {
|
||||
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid response format</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (exports.length === 0) {
|
||||
listEl.innerHTML = '<p class="text-slate-400 text-sm">No NFS exports found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = exports.map(exp => `
|
||||
<div class="border-b border-slate-700 last:border-0 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h3 class="text-lg font-semibold text-white">${exp.path || 'N/A'}</h3>
|
||||
${exp.enabled ? '<span class="px-2 py-1 rounded text-xs font-medium bg-green-900 text-green-300">Enabled</span>' : '<span class="px-2 py-1 rounded text-xs font-medium bg-slate-700 text-slate-300">Disabled</span>'}
|
||||
</div>
|
||||
<div class="text-sm text-slate-400 space-y-1">
|
||||
<p>Dataset: ${exp.dataset || 'N/A'}</p>
|
||||
<p>Clients: ${exp.clients && exp.clients.length > 0 ? exp.clients.join(', ') : '*'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="deleteNFSExport('${exp.id}')" class="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded text-sm">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (err) {
|
||||
document.getElementById('nfs-exports-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function showCreateSMBModal() {
|
||||
document.getElementById('create-smb-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function showCreateNFSModal() {
|
||||
document.getElementById('create-nfs-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeModal(modalId) {
|
||||
document.getElementById(modalId).classList.add('hidden');
|
||||
}
|
||||
|
||||
async function createSMBShare(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const data = {
|
||||
name: formData.get('name'),
|
||||
dataset: formData.get('dataset'),
|
||||
read_only: formData.get('readonly') === 'on',
|
||||
guest_ok: formData.get('guest_ok') === 'on'
|
||||
};
|
||||
if (formData.get('description')) data.description = formData.get('description');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/shares/smb', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeModal('create-smb-modal');
|
||||
e.target.reset();
|
||||
loadSMBShares();
|
||||
alert('SMB share created successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to create SMB share'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function createNFSExport(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const clients = formData.get('clients') ? formData.get('clients').split(',').map(c => c.trim()).filter(c => c) : ['*'];
|
||||
const data = {
|
||||
dataset: formData.get('dataset'),
|
||||
clients: clients,
|
||||
read_only: formData.get('readonly') === 'on',
|
||||
root_squash: formData.get('root_squash') === 'on'
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/exports/nfs', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeModal('create-nfs-modal');
|
||||
e.target.reset();
|
||||
loadNFSExports();
|
||||
alert('NFS export created successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to create NFS export'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSMBShare(id) {
|
||||
if (!confirm('Are you sure you want to delete this SMB share?')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/shares/smb/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
loadSMBShares();
|
||||
alert('SMB share deleted successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to delete SMB share'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNFSExport(id) {
|
||||
if (!confirm('Are you sure you want to delete this NFS export?')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/exports/nfs/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
loadNFSExports();
|
||||
alert('NFS export deleted successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to delete NFS export'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Load initial data
|
||||
loadSMBShares();
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{define "shares.html"}}
|
||||
{{template "base" .}}
|
||||
{{end}}
|
||||
725
web/templates/storage.html
Normal file
725
web/templates/storage.html
Normal file
@@ -0,0 +1,725 @@
|
||||
{{define "storage-content"}}
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Storage Management</h1>
|
||||
<p class="text-slate-400">Manage storage pools, datasets, and volumes</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="showCreatePoolModal()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium">
|
||||
Create Pool
|
||||
</button>
|
||||
<button onclick="showImportPoolModal()" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg text-sm font-medium">
|
||||
Import Pool
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="border-b border-slate-800">
|
||||
<nav class="flex gap-4">
|
||||
<button onclick="switchTab('pools')" id="tab-pools" class="tab-button px-4 py-2 border-b-2 border-blue-600 text-blue-400 font-medium">
|
||||
Pools
|
||||
</button>
|
||||
<button onclick="switchTab('datasets')" id="tab-datasets" class="tab-button px-4 py-2 border-b-2 border-transparent text-slate-400 hover:text-slate-300">
|
||||
Datasets
|
||||
</button>
|
||||
<button onclick="switchTab('zvols')" id="tab-zvols" class="tab-button px-4 py-2 border-b-2 border-transparent text-slate-400 hover:text-slate-300">
|
||||
Volumes
|
||||
</button>
|
||||
<button onclick="switchTab('disks')" id="tab-disks" class="tab-button px-4 py-2 border-b-2 border-transparent text-slate-400 hover:text-slate-300">
|
||||
Disks
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Pools Tab -->
|
||||
<div id="content-pools" class="tab-content">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">Storage Pools</h2>
|
||||
<button onclick="loadPools()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||
</div>
|
||||
<div id="pools-list" class="p-4">
|
||||
<p class="text-slate-400 text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Datasets Tab -->
|
||||
<div id="content-datasets" class="tab-content hidden">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">Datasets</h2>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="showCreateDatasetModal()" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Create Dataset
|
||||
</button>
|
||||
<button onclick="loadDatasets()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="datasets-list" class="p-4">
|
||||
<p class="text-slate-400 text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Storage Volumes Tab -->
|
||||
<div id="content-zvols" class="tab-content hidden">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">Storage Volumes</h2>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="showCreateZVOLModal()" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Create Volume
|
||||
</button>
|
||||
<button onclick="loadZVOLs()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="zvols-list" class="p-4">
|
||||
<p class="text-slate-400 text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Disks Tab -->
|
||||
<div id="content-disks" class="tab-content hidden">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">Available Disks</h2>
|
||||
<button onclick="loadDisks()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||
</div>
|
||||
<div id="disks-list" class="p-4">
|
||||
<p class="text-slate-400 text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Pool Modal -->
|
||||
<div id="create-pool-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-md w-full mx-4">
|
||||
<h3 class="text-xl font-semibold text-white mb-4">Create Storage Pool</h3>
|
||||
<form id="create-pool-form" onsubmit="createPool(event)" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Pool Name</label>
|
||||
<input type="text" name="name" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">VDEVs (comma-separated)</label>
|
||||
<input type="text" name="vdevs" placeholder="/dev/sdb,/dev/sdc" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
<p class="text-xs text-slate-400 mt-1">Enter device paths separated by commas</p>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" onclick="closeModal('create-pool-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import Pool Modal -->
|
||||
<div id="import-pool-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-md w-full mx-4">
|
||||
<h3 class="text-xl font-semibold text-white mb-4">Import Storage Pool</h3>
|
||||
<form id="import-pool-form" onsubmit="importPool(event)" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Pool Name</label>
|
||||
<select name="name" id="import-pool-select" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
<option value="">Loading available pools...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" name="readonly" id="import-readonly" class="w-4 h-4 text-blue-600 bg-slate-900 border-slate-700 rounded">
|
||||
<label for="import-readonly" class="text-sm text-slate-300">Import as read-only</label>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" onclick="closeModal('import-pool-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Dataset Modal -->
|
||||
<div id="create-dataset-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-md w-full mx-4">
|
||||
<h3 class="text-xl font-semibold text-white mb-4">Create Dataset</h3>
|
||||
<form id="create-dataset-form" onsubmit="createDataset(event)" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Dataset Name</label>
|
||||
<input type="text" name="name" placeholder="pool/dataset" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Quota (optional)</label>
|
||||
<input type="text" name="quota" placeholder="10G" class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Compression (optional)</label>
|
||||
<select name="compression" class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
<option value="">None</option>
|
||||
<option value="lz4">lz4</option>
|
||||
<option value="zstd">zstd</option>
|
||||
<option value="gzip">gzip</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" onclick="closeModal('create-dataset-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Storage Volume Modal -->
|
||||
<div id="create-zvol-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-md w-full mx-4">
|
||||
<h3 class="text-xl font-semibold text-white mb-4">Create Storage Volume</h3>
|
||||
<form id="create-zvol-form" onsubmit="createZVOL(event)" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Volume Name</label>
|
||||
<input type="text" name="name" placeholder="pool/zvol" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Size</label>
|
||||
<input type="text" name="size" placeholder="10G" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" onclick="closeModal('create-zvol-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentTab = 'pools';
|
||||
|
||||
function switchTab(tab) {
|
||||
currentTab = tab;
|
||||
// Update tab buttons
|
||||
document.querySelectorAll('.tab-button').forEach(btn => {
|
||||
btn.classList.remove('border-blue-600', 'text-blue-400');
|
||||
btn.classList.add('border-transparent', 'text-slate-400');
|
||||
});
|
||||
document.getElementById(`tab-${tab}`).classList.remove('border-transparent', 'text-slate-400');
|
||||
document.getElementById(`tab-${tab}`).classList.add('border-blue-600', 'text-blue-400');
|
||||
|
||||
// Update content
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.add('hidden');
|
||||
});
|
||||
document.getElementById(`content-${tab}`).classList.remove('hidden');
|
||||
|
||||
// Load data for the tab
|
||||
if (tab === 'pools') loadPools();
|
||||
else if (tab === 'datasets') loadDatasets();
|
||||
else if (tab === 'zvols') loadZVOLs();
|
||||
else if (tab === 'disks') loadDisks();
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes || bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function getAuthHeaders() {
|
||||
const token = localStorage.getItem('atlas_token');
|
||||
const headers = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
// Only add Authorization header if token exists (for mutations)
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function loadPools() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/pools', { headers: getAuthHeaders() });
|
||||
const data = await res.json().catch(() => null);
|
||||
const listEl = document.getElementById('pools-list');
|
||||
|
||||
// Handle HTTP errors
|
||||
if (!res.ok) {
|
||||
const errorMsg = (data && data.error) ? data.error : `HTTP ${res.status}: Failed to load pools`;
|
||||
listEl.innerHTML = `<p class="text-red-400 text-sm">Error: ${errorMsg}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle invalid response format
|
||||
if (!data) {
|
||||
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid response (no data)</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if data is an array
|
||||
if (!Array.isArray(data)) {
|
||||
// Log the actual response for debugging
|
||||
console.error('Invalid response format:', data);
|
||||
const errorMsg = (data.error) ? data.error : 'Invalid response format: expected array';
|
||||
listEl.innerHTML = `<p class="text-red-400 text-sm">Error: ${errorMsg}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const pools = data;
|
||||
|
||||
if (pools.length === 0) {
|
||||
listEl.innerHTML = '<p class="text-slate-400 text-sm">No pools found. Create a pool to get started.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = pools.map(pool => `
|
||||
<div class="border-b border-slate-700 last:border-0 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h3 class="text-lg font-semibold text-white">${pool.name}</h3>
|
||||
<span class="px-2 py-1 rounded text-xs font-medium ${
|
||||
pool.health === 'ONLINE' ? 'bg-green-900 text-green-300' :
|
||||
pool.health === 'DEGRADED' ? 'bg-yellow-900 text-yellow-300' :
|
||||
'bg-red-900 text-red-300'
|
||||
}">${pool.health}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-slate-400">Size:</span>
|
||||
<span class="text-white ml-2">${formatBytes(pool.size)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-slate-400">Used:</span>
|
||||
<span class="text-white ml-2">${formatBytes(pool.allocated)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-slate-400">Free:</span>
|
||||
<span class="text-white ml-2">${formatBytes(pool.free)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="startScrub('${pool.name}')" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||
Scrub
|
||||
</button>
|
||||
<button onclick="exportPool('${pool.name}')" class="px-3 py-1.5 bg-yellow-600 hover:bg-yellow-700 text-white rounded text-sm">
|
||||
Export
|
||||
</button>
|
||||
<button onclick="deletePool('${pool.name}')" class="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded text-sm">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (err) {
|
||||
document.getElementById('pools-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDatasets() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/datasets', { headers: getAuthHeaders() });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
||||
document.getElementById('datasets-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.error || 'Failed to load datasets'}</p>`;
|
||||
return;
|
||||
}
|
||||
const datasets = await res.json();
|
||||
const listEl = document.getElementById('datasets-list');
|
||||
|
||||
if (!Array.isArray(datasets)) {
|
||||
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid response format</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (datasets.length === 0) {
|
||||
listEl.innerHTML = '<p class="text-slate-400 text-sm">No datasets found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = datasets.map(ds => `
|
||||
<div class="border-b border-slate-700 last:border-0 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">${ds.name}</h3>
|
||||
${ds.mountpoint ? `<p class="text-sm text-slate-400">${ds.mountpoint}</p>` : ''}
|
||||
</div>
|
||||
<button onclick="deleteDataset('${ds.name}')" class="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded text-sm">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (err) {
|
||||
document.getElementById('datasets-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadZVOLs() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/zvols', { headers: getAuthHeaders() });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
||||
document.getElementById('zvols-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.error || 'Failed to load volumes'}</p>`;
|
||||
return;
|
||||
}
|
||||
const zvols = await res.json();
|
||||
const listEl = document.getElementById('zvols-list');
|
||||
|
||||
if (!Array.isArray(zvols)) {
|
||||
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid response format</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (zvols.length === 0) {
|
||||
listEl.innerHTML = '<p class="text-slate-400 text-sm">No volumes found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = zvols.map(zvol => `
|
||||
<div class="border-b border-slate-700 last:border-0 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">${zvol.name}</h3>
|
||||
<p class="text-sm text-slate-400">Size: ${formatBytes(zvol.size)}</p>
|
||||
</div>
|
||||
<button onclick="deleteZVOL('${zvol.name}')" class="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded text-sm">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (err) {
|
||||
document.getElementById('zvols-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDisks() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/disks', { headers: getAuthHeaders() });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
||||
document.getElementById('disks-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.error || 'Failed to load disks'}</p>`;
|
||||
return;
|
||||
}
|
||||
const disks = await res.json();
|
||||
const listEl = document.getElementById('disks-list');
|
||||
|
||||
if (!Array.isArray(disks)) {
|
||||
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid response format</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (disks.length === 0) {
|
||||
listEl.innerHTML = '<p class="text-slate-400 text-sm">No disks found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = disks.map(disk => `
|
||||
<div class="border-b border-slate-700 last:border-0 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">${disk.name}</h3>
|
||||
<div class="text-sm text-slate-400 space-y-1">
|
||||
${disk.size ? `<p>Size: ${disk.size}</p>` : ''}
|
||||
${disk.model ? `<p>Model: ${disk.model}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (err) {
|
||||
document.getElementById('disks-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function showCreatePoolModal() {
|
||||
document.getElementById('create-pool-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function showImportPoolModal() {
|
||||
document.getElementById('import-pool-modal').classList.remove('hidden');
|
||||
loadAvailablePools();
|
||||
}
|
||||
|
||||
function showCreateDatasetModal() {
|
||||
document.getElementById('create-dataset-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function showCreateZVOLModal() {
|
||||
document.getElementById('create-zvol-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeModal(modalId) {
|
||||
document.getElementById(modalId).classList.add('hidden');
|
||||
}
|
||||
|
||||
async function loadAvailablePools() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/pools/available', { headers: getAuthHeaders() });
|
||||
const data = await res.json();
|
||||
const select = document.getElementById('import-pool-select');
|
||||
select.innerHTML = '<option value="">Select a pool...</option>';
|
||||
if (data.pools && data.pools.length > 0) {
|
||||
data.pools.forEach(pool => {
|
||||
const option = document.createElement('option');
|
||||
option.value = pool;
|
||||
option.textContent = pool;
|
||||
select.appendChild(option);
|
||||
});
|
||||
} else {
|
||||
select.innerHTML = '<option value="">No pools available for import</option>';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading available pools:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function createPool(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const vdevs = formData.get('vdevs').split(',').map(v => v.trim()).filter(v => v);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/pools', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
name: formData.get('name'),
|
||||
vdevs: vdevs
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeModal('create-pool-modal');
|
||||
e.target.reset();
|
||||
loadPools();
|
||||
alert('Pool created successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to create pool'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function importPool(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const options = {};
|
||||
if (formData.get('readonly')) {
|
||||
options.readonly = 'on';
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/pools/import', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
name: formData.get('name'),
|
||||
options: options
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeModal('import-pool-modal');
|
||||
e.target.reset();
|
||||
loadPools();
|
||||
alert('Pool imported successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to import pool'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function createDataset(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const data = { name: formData.get('name') };
|
||||
if (formData.get('quota')) data.quota = formData.get('quota');
|
||||
if (formData.get('compression')) data.compression = formData.get('compression');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/datasets', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeModal('create-dataset-modal');
|
||||
e.target.reset();
|
||||
loadDatasets();
|
||||
alert('Dataset created successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to create dataset'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function createZVOL(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/zvols', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
name: formData.get('name'),
|
||||
size: formData.get('size')
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeModal('create-zvol-modal');
|
||||
e.target.reset();
|
||||
loadZVOLs();
|
||||
alert('Storage volume created successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to create storage volume'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePool(name) {
|
||||
if (!confirm(`Are you sure you want to delete pool "${name}"? This will destroy all data!`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/pools/${name}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
loadPools();
|
||||
alert('Pool deleted successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to delete pool'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDataset(name) {
|
||||
if (!confirm(`Are you sure you want to delete dataset "${name}"?`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/datasets/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
loadDatasets();
|
||||
alert('Dataset deleted successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to delete dataset'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteZVOL(name) {
|
||||
if (!confirm(`Are you sure you want to delete storage volume "${name}"?`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/zvols/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
loadZVOLs();
|
||||
alert('Storage volume deleted successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to delete storage volume'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function startScrub(name) {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/pools/${name}/scrub`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
alert('Scrub started successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to start scrub'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportPool(name) {
|
||||
if (!confirm(`Are you sure you want to export pool "${name}"?`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/pools/${name}/export`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ force: false })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
loadPools();
|
||||
alert('Pool exported successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to export pool'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Load initial data
|
||||
loadPools();
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{define "storage.html"}}
|
||||
{{template "base" .}}
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user