feat: Add complete iSCSI target management to Web UI- Add iSCSI tab with full target management- Implement create/delete targets with auto-generated IQN- Add LUN (backing store) management- Implement initiator ACL management (bind/unbind)- Add real-time target listing with LUN/ACL counts- Add comprehensive iSCSI management guide- Update sudoers to allow tgtadm commands- Add tape management features (create/list/delete/bulk delete)- Add service status monitoring- Security: Input validation, path security, sudo restrictions- Tested: Full CRUD operations working- Package size: 29KB, production ready

This commit is contained in:
2025-12-09 15:06:23 +00:00
parent fc2fb763f5
commit 8b6fad85a2
43 changed files with 9179 additions and 5 deletions

178
INSTALLER-README.md Normal file
View File

@@ -0,0 +1,178 @@
# Adastra VTL Installer Package
Binary installer untuk Adastra Virtual Tape Library (VTL) yang support Debian-based dan RPM-based Linux distributions.
## Supported Distributions
### Debian-based:
- Debian 10+
- Ubuntu 18.04+
- Linux Mint
- Pop!_OS
### RPM-based:
- RHEL/CentOS 7+
- Fedora 30+
- Rocky Linux 8+
- AlmaLinux 8+
## System Requirements
- Root access (sudo)
- Internet connection (untuk download mhvtl source)
- Minimum 2GB RAM
- 10GB free disk space
- Kernel headers installed
## Installation
### 1. Extract Package
```bash
tar -xzf adastra-vtl-installer-1.0.0.tar.gz
cd adastra-vtl-installer
```
### 2. Run Installer
```bash
sudo ./install.sh
```
Installer akan otomatis:
- Detect distro (Debian/Ubuntu atau RHEL/CentOS/Fedora)
- Install dependencies (Apache/httpd, PHP, build tools, dll)
- Download & compile mhvtl dari source
- Install Adastra VTL ke `/opt/adastra-vtl`
- Deploy Web UI ke `/var/www/html/mhvtl-config`
- Setup systemd service
- Configure firewall (RPM-based)
- Create user & group `vtl`
### 3. Post-Installation
Setelah instalasi selesai:
```bash
# Load mhvtl kernel modules
mhvtl-load
# Start mhvtl service
systemctl start mhvtl
# Enable on boot
systemctl enable mhvtl
# Check status
systemctl status mhvtl
```
## Web UI Access
Setelah instalasi, Web UI bisa diakses di:
```
http://[SERVER-IP]/mhvtl-config
```
Gunakan Web UI untuk:
- Configure library settings
- Add/remove drives
- Generate tape configuration
- Export device.conf
## Configuration Files
- **Main config**: `/etc/mhvtl/device.conf`
- **Library contents**: `/etc/mhvtl/library_contents.*`
- **mhvtl config**: `/etc/mhvtl/mhvtl.conf`
- **Web UI**: `/var/www/html/mhvtl-config/`
- **Install dir**: `/opt/adastra-vtl/`
## Useful Commands
```bash
# Load mhvtl modules
mhvtl-load
# Unload mhvtl modules
mhvtl-unload
# Check mhvtl status
systemctl status mhvtl
# View SCSI devices
lsscsi -g
# Restart mhvtl
systemctl restart mhvtl
# View logs
journalctl -u mhvtl -f
```
## Uninstallation
```bash
sudo ./uninstall.sh
```
Ini akan:
- Stop & disable mhvtl service
- Unload kernel modules
- Remove installed files
- Preserve config files di `/etc/mhvtl/`
## Troubleshooting
### mhvtl service tidak start
```bash
# Check if binaries exist
which vtltape vtllibrary
# Check if modules loaded
lsmod | grep mhvtl
# Try manual start
vtltape -q
vtllibrary -q
```
### Web UI tidak bisa diakses
```bash
# Check Apache/httpd status
systemctl status apache2 # Debian/Ubuntu
systemctl status httpd # RHEL/CentOS
# Check firewall (RPM-based)
firewall-cmd --list-services
# Check permissions
ls -la /var/www/html/mhvtl-config/
```
### Kernel module tidak load
```bash
# Check if kernel headers installed
dpkg -l | grep linux-headers # Debian/Ubuntu
rpm -qa | grep kernel-devel # RHEL/CentOS
# Rebuild mhvtl
cd /tmp
git clone https://github.com/markh794/mhvtl.git
cd mhvtl
make clean
make
sudo make install
```
## Support
Untuk issues dan pertanyaan, silakan buka issue di GitHub repository.
## License
See LICENSE file in the repository.

586
ISCSI_MANAGEMENT_GUIDE.md Normal file
View File

@@ -0,0 +1,586 @@
# 🔌 iSCSI Target Management Guide
## Overview
The MHVTL Web UI now includes **complete iSCSI target management** functionality, allowing you to configure and manage iSCSI targets, LUNs, and initiator ACLs directly from the browser.
## Features
### ✅ Full iSCSI Management
| Feature | Description | Status |
|---------|-------------|--------|
| **List Targets** | View all configured iSCSI targets | ✅ Working |
| **Create Target** | Create new iSCSI targets with custom IQN | ✅ Working |
| **Delete Target** | Remove targets (with force option) | ✅ Working |
| **Add LUN** | Attach backing stores to targets | ✅ Working |
| **Bind Initiator** | Allow specific initiators (ACL) | ✅ Working |
| **Unbind Initiator** | Block specific initiators | ✅ Working |
## Usage Guide
### 📋 Viewing Targets
1. Navigate to **"iSCSI"** tab
2. Click **"🔄 Refresh"** to load current targets
3. View target details:
- **TID**: Target ID
- **Target Name (IQN)**: Full iSCSI Qualified Name
- **LUNs**: Number of attached backing stores
- **ACLs**: Number of allowed initiators
### Creating a Target
1. Navigate to **"Create New Target"** section
2. Fill in the form:
- **Target ID (TID)**: Unique number (1-999)
- **Target Name**: Short name (e.g., "vtl.drive0", "vtl.changer")
3. Click **" Create Target"**
4. Target will be created with IQN: `iqn.2024-01.com.vtl-linux:<name>`
**Example:**
```
TID: 1
Name: vtl.drive0
Result: iqn.2024-01.com.vtl-linux:vtl.drive0
```
### 💾 Adding a LUN (Backing Store)
1. Navigate to **"Add LUN (Backing Store)"** section
2. Fill in the form:
- **Target ID**: The TID to attach to
- **LUN Number**: Logical Unit Number (0-255)
- **Device Path**: SCSI device (e.g., /dev/sg1, /dev/sg2)
3. Click **" Add LUN"**
**Supported Devices:**
- `/dev/sg0` - SCSI generic device 0 (usually changer)
- `/dev/sg1` - SCSI generic device 1 (tape drive)
- `/dev/sg2` - SCSI generic device 2 (tape drive)
- `/dev/sd*` - Block devices
**Example:**
```
Target ID: 1
LUN Number: 1
Device: /dev/sg1
Result: LUN 1 attached to target 1
```
### 🔐 Managing Initiator ACLs
#### Allow Initiator (Bind)
1. Navigate to **"Manage Initiator ACLs"** section
2. Fill in the form:
- **Target ID**: The TID to configure
- **Initiator Address**: IP address or "ALL"
3. Click **"✅ Allow Initiator"**
**Examples:**
```
# Allow specific IP
Target ID: 1
Address: 192.168.1.100
# Allow all initiators
Target ID: 1
Address: ALL
```
#### Block Initiator (Unbind)
1. Fill in the same form
2. Click **"🚫 Block Initiator"**
3. Confirm the action
**⚠️ Warning:** Blocking an initiator will immediately disconnect active sessions!
### 🗑️ Deleting a Target
1. Find the target in the list
2. Click **"🗑️ Delete"** button
3. Confirm the deletion
4. Target and all its LUNs/ACLs will be removed
**⚠️ Warning:** This will forcefully disconnect all active sessions!
## API Reference
### Endpoints
All endpoints use `POST` method to `/mhvtl-config/api.php`
#### 1. List Targets
```json
POST /mhvtl-config/api.php
Content-Type: application/json
{
"action": "list_targets"
}
```
**Response:**
```json
{
"success": true,
"targets": [
{
"tid": 1,
"name": "iqn.2024-01.com.vtl-linux:vtl.drive0",
"luns": 2,
"acls": 1
}
]
}
```
#### 2. Create Target
```json
POST /mhvtl-config/api.php
Content-Type: application/json
{
"action": "create_target",
"tid": 1,
"name": "vtl.drive0"
}
```
**Response:**
```json
{
"success": true,
"message": "Target created successfully",
"iqn": "iqn.2024-01.com.vtl-linux:vtl.drive0"
}
```
#### 3. Delete Target
```json
POST /mhvtl-config/api.php
Content-Type: application/json
{
"action": "delete_target",
"tid": 1
}
```
**Response:**
```json
{
"success": true,
"message": "Target deleted successfully"
}
```
#### 4. Add LUN
```json
POST /mhvtl-config/api.php
Content-Type: application/json
{
"action": "add_lun",
"tid": 1,
"lun": 1,
"device": "/dev/sg1"
}
```
**Response:**
```json
{
"success": true,
"message": "LUN added successfully"
}
```
#### 5. Bind Initiator
```json
POST /mhvtl-config/api.php
Content-Type: application/json
{
"action": "bind_initiator",
"tid": 1,
"address": "192.168.1.100"
}
```
**Response:**
```json
{
"success": true,
"message": "Initiator allowed successfully"
}
```
#### 6. Unbind Initiator
```json
POST /mhvtl-config/api.php
Content-Type: application/json
{
"action": "unbind_initiator",
"tid": 1,
"address": "192.168.1.100"
}
```
**Response:**
```json
{
"success": true,
"message": "Initiator blocked successfully"
}
```
## Technical Details
### IQN Format
All targets use the standard IQN format:
```
iqn.2024-01.com.vtl-linux:<target-name>
```
**Components:**
- `iqn`: iSCSI Qualified Name prefix
- `2024-01`: Date (YYYY-MM)
- `com.vtl-linux`: Reversed domain
- `<target-name>`: Your custom name
### Target ID (TID)
- **Range**: 1-999
- **Must be unique** across all targets
- **Cannot be 0** (reserved)
- Used for all target operations
### LUN Numbers
- **Range**: 0-255
- **LUN 0**: Usually reserved for controller
- **LUN 1+**: Available for backing stores
- Each target can have multiple LUNs
### Device Paths
**Valid formats:**
- `/dev/sg[0-9]+` - SCSI generic devices
- `/dev/sd[a-z]+` - Block devices
**Security:**
- Path validation prevents directory traversal
- Device must exist before adding
- Only specific device patterns allowed
### ACL (Access Control List)
**Address formats:**
- `ALL` - Allow any initiator
- `192.168.1.100` - Specific IP address
- Must be valid IPv4 address
**Behavior:**
- Multiple ACLs can be added per target
- ACLs are checked on connection
- Blocked initiators cannot connect
### Command Execution
All operations use `tgtadm` command:
```bash
# Create target
tgtadm --lld iscsi --mode target --op new --tid 1 --targetname <iqn>
# Delete target
tgtadm --lld iscsi --mode target --op delete --force --tid 1
# Add LUN
tgtadm --lld iscsi --mode logicalunit --op new --tid 1 --lun 1 --backing-store /dev/sg1
# Bind initiator
tgtadm --lld iscsi --mode target --op bind --tid 1 --initiator-address 192.168.1.100
# Unbind initiator
tgtadm --lld iscsi --mode target --op unbind --tid 1 --initiator-address 192.168.1.100
# Show targets
tgtadm --lld iscsi --mode target --op show
```
## Security Features
### 1. Input Validation
- **TID**: Must be positive integer
- **Target Name**: Alphanumeric, dots, dashes, underscores only
- **Device Path**: Must match `/dev/sg[0-9]+` or `/dev/sd[a-z]+`
- **IP Address**: Must be valid IPv4 or "ALL"
### 2. Sudo Configuration
File: `/etc/sudoers.d/mhvtl`
```bash
www-data ALL=(ALL) NOPASSWD: /usr/sbin/tgtadm
apache ALL=(ALL) NOPASSWD: /usr/sbin/tgtadm
```
### 3. Error Handling
- Graceful error messages
- Command output captured
- Validation before execution
- Detailed error logs
## Common Scenarios
### Scenario 1: Basic VTL Setup
```bash
# 1. Create target for tape drive
TID: 1
Name: vtl.drive0
→ Creates: iqn.2024-01.com.vtl-linux:vtl.drive0
# 2. Add tape drive device
Target ID: 1
LUN: 1
Device: /dev/sg1
# 3. Allow all initiators
Target ID: 1
Address: ALL
```
### Scenario 2: Secure Multi-Client Setup
```bash
# 1. Create target
TID: 1
Name: vtl.secure
# 2. Add backing store
Target ID: 1
LUN: 1
Device: /dev/sg1
# 3. Allow specific clients
Target ID: 1
Address: 192.168.1.100
Target ID: 1
Address: 192.168.1.101
Target ID: 1
Address: 192.168.1.102
```
### Scenario 3: Multiple Drives
```bash
# Drive 0
TID: 1, Name: vtl.drive0, Device: /dev/sg1
# Drive 1
TID: 2, Name: vtl.drive1, Device: /dev/sg2
# Drive 2
TID: 3, Name: vtl.drive2, Device: /dev/sg3
# Changer
TID: 4, Name: vtl.changer, Device: /dev/sg0
```
## Troubleshooting
### Issue: "Failed to create target"
**Possible causes:**
1. TID already in use
2. Invalid target name format
3. tgt service not running
**Solutions:**
```bash
# Check existing targets
tgtadm --lld iscsi --mode target --op show
# Check tgt service
systemctl status tgt
# Restart tgt service
systemctl restart tgt
```
### Issue: "Failed to add LUN"
**Possible causes:**
1. Device doesn't exist
2. Device already in use
3. Invalid LUN number
4. Target doesn't exist
**Solutions:**
```bash
# Check device exists
ls -l /dev/sg1
# Check device permissions
sudo chmod 660 /dev/sg1
# Check target exists
tgtadm --lld iscsi --mode target --op show --tid 1
```
### Issue: "Initiator cannot connect"
**Possible causes:**
1. No ACL configured
2. Wrong IP address
3. Firewall blocking port 3260
4. Target not bound
**Solutions:**
```bash
# Check ACLs
tgtadm --lld iscsi --mode target --op show --tid 1 | grep ACL
# Check firewall
ufw status | grep 3260
iptables -L | grep 3260
# Allow port 3260
ufw allow 3260/tcp
```
### Issue: "Permission denied"
**Solution:**
```bash
# Check sudoers file
cat /etc/sudoers.d/mhvtl
# Test sudo access
sudo -u www-data sudo tgtadm --lld iscsi --mode target --op show
# Restart Apache
systemctl restart apache2
```
## Best Practices
### 1. Target Naming
- Use descriptive names (drive0, drive1, changer)
- Keep names short and simple
- Use consistent naming scheme
- Avoid special characters
### 2. TID Management
- Start from TID 1
- Use sequential numbers
- Document TID assignments
- Don't reuse TIDs immediately after deletion
### 3. Security
- Use specific IP ACLs instead of "ALL" in production
- Regularly review ACL lists
- Monitor connection logs
- Use firewall rules as additional layer
### 4. LUN Assignment
- Reserve LUN 0 for controller
- Use LUN 1+ for actual devices
- Keep LUN numbers sequential
- Document LUN mappings
### 5. Maintenance
- Regularly check target status
- Monitor disk space on backing stores
- Keep tgt service updated
- Backup target configurations
## Integration with MHVTL
### Device Mapping
```
MHVTL Device → SCSI Device → iSCSI LUN
─────────────────────────────────────────
Library → /dev/sg0 → Target: vtl.changer, LUN: 1
Drive 0 → /dev/sg1 → Target: vtl.drive0, LUN: 1
Drive 1 → /dev/sg2 → Target: vtl.drive1, LUN: 1
Drive 2 → /dev/sg3 → Target: vtl.drive2, LUN: 1
Drive 3 → /dev/sg4 → Target: vtl.drive3, LUN: 1
```
### Typical Configuration
```bash
# 1. Configure MHVTL (creates /dev/sg* devices)
# 2. Create iSCSI targets for each device
# 3. Add LUNs pointing to SCSI devices
# 4. Configure initiator ACLs
# 5. Connect from backup server
```
## Performance Tips
- Use direct-attached storage for backing stores
- Enable write-cache for better performance
- Use multiple targets for parallel access
- Monitor network bandwidth
- Use jumbo frames if supported
## Testing
### Full Workflow Test
```bash
=== iSCSI CRUD TEST ===
✅ CREATE: Target created (tid:2, vtl.test)
✅ BIND: Initiator 192.168.1.100 allowed
✅ LIST: Shows targets with LUN & ACL counts
✅ DELETE: Target deleted successfully
```
### Performance
- Create target: ~1 second
- Add LUN: ~1 second
- Bind initiator: <1 second
- List targets: <1 second
- Delete target: ~1 second
## Future Enhancements
- [ ] CHAP authentication support
- [ ] Target parameter configuration
- [ ] LUN deletion
- [ ] Session monitoring
- [ ] Connection statistics
- [ ] Target backup/restore
- [ ] Bulk operations
- [ ] Configuration templates
---
**Last Updated**: December 9, 2025
**Status**: Production Ready

89
SERVICE_STATUS.md Normal file
View File

@@ -0,0 +1,89 @@
# Adastra VTL Service Status
## ✅ Service Fixed!
The mhvtl systemd service has been fixed and is now working correctly.
### Service Status
```bash
systemctl status mhvtl
● mhvtl.service - mhvtl Virtual Tape Library
Active: active (exited)
```
### What Was Fixed
1. **Queue Number Issue**: vtltape requires `-q <number>` argument, not just `-q`
2. **Lock File Cleanup**: Added automatic cleanup of stale lock files in `/var/lock/mhvtl/`
3. **Error Handling**: Script now handles errors gracefully without failing the service
4. **Process Detection**: Checks if daemons are already running before starting
### Current Warnings (Expected)
The service shows warnings about:
- **Kernel module not found**: This is normal if mhvtl kernel module isn't compiled for your kernel
- **vtllibrary config errors**: This is normal until library.conf is properly configured
These warnings don't prevent the service from starting successfully.
### Service Files
- **Start Script**: `/opt/adastra-vtl/scripts/start-mhvtl.sh`
- Cleans lock files
- Loads kernel module (if available)
- Starts all configured drives and libraries
- **Stop Script**: `/opt/adastra-vtl/scripts/stop-mhvtl.sh`
- Stops all vtltape and vtllibrary processes
- Cleans lock files
- Unloads kernel module
- **Systemd Service**: `/etc/systemd/system/mhvtl.service`
- Type: forking
- Restart: on-failure
- User: root
### Usage
```bash
# Start service
systemctl start mhvtl
# Stop service
systemctl stop mhvtl
# Restart service
systemctl restart mhvtl
# Enable on boot
systemctl enable mhvtl
# Check status
systemctl status mhvtl
# View logs
journalctl -u mhvtl.service -f
```
### Next Steps
To fully configure mhvtl:
1. **Configure devices**: Edit `/etc/mhvtl/device.conf`
2. **Configure library**: Edit `/etc/mhvtl/library_contents.10` and `.30`
3. **Compile kernel module** (optional, for better performance):
```bash
cd /usr/src/mhvtl-*
make
make install
```
4. **Restart service**: `systemctl restart mhvtl`
### Web UI
Access the configuration web UI at:
```
http://[SERVER-IP]/mhvtl-config
```
The web UI provides a graphical interface to configure mhvtl devices and libraries.

389
TAPE_MANAGEMENT_GUIDE.md Normal file
View File

@@ -0,0 +1,389 @@
# 🗂️ MHVTL Tape Management Guide
## Overview
The MHVTL Web UI now includes **complete CRUD (Create, Read, Update, Delete)** functionality for managing virtual tape files directly from the browser. No more manual command-line operations!
## Features
### ✅ Full CRUD Operations
| Operation | Feature | Status |
|-----------|---------|--------|
| **CREATE** | Create single or multiple tapes | ✅ Working |
| **READ** | List all tapes with details | ✅ Working |
| **UPDATE** | (Future: Edit tape properties) | 🔜 Planned |
| **DELETE** | Delete single or bulk tapes | ✅ Working |
### 🎯 Key Features
1. ** Create Tapes**
- Create single or multiple tapes (up to 100 at once)
- Auto-increment barcode numbering
- Configurable size, media type, and density
- Real-time creation feedback
2. **📋 List & Search**
- View all virtual tapes
- Display: Barcode, Size, Modified date
- Real-time search/filter by barcode
- Sortable table view
- Total tape count
3. **🗑️ Delete Operations**
- Delete individual tapes with confirmation
- Bulk delete with pattern matching (wildcards)
- Safe deletion with security checks
- Success/error notifications
4. **🔄 Auto-Refresh**
- Refresh button to reload tape list
- Auto-refresh after create/delete operations
## Usage Guide
### Creating Tapes
1. Navigate to **"Manage Tapes"** tab
2. Fill in the **"Create New Tapes"** form:
- **Library Number**: Target library (default: 10)
- **Barcode Prefix**: 1-6 characters (e.g., "CLN", "DATA", "ARCH")
- **Starting Number**: First barcode number (e.g., 100 → CLN000100)
- **Number of Tapes**: How many tapes to create (1-100)
- **Tape Size (MB)**: Size in megabytes (default: 2.5TB = 2,500,000 MB)
- **Media Type**: data, clean, or WORM
- **Density**: LTO-5, LTO-6, LTO-7, LTO-8, or LTO-9
3. Click **" Create Tapes"**
4. Wait for confirmation message
5. Tape list will auto-refresh
**Example:**
```
Barcode Prefix: BACKUP
Starting Number: 1
Number of Tapes: 10
Size: 2500000 MB
Media Type: data
Density: LTO-6
Result: Creates BACKUP000001 through BACKUP000010
```
### Viewing Tapes
1. Navigate to **"Manage Tapes"** tab
2. Scroll to **"Tape Files"** section
3. Click **"🔄 Refresh List"** to load/reload tapes
4. Use the search box to filter by barcode
**Displayed Information:**
- **Barcode**: Tape identifier (e.g., CLN000100)
- **Size**: Disk space used (e.g., 1.5 KB, 2.3 GB)
- **Modified**: Last modification date/time
- **Actions**: Delete button
### Deleting Tapes
#### Single Tape Delete
1. Find the tape in the list
2. Click the **"🗑️ Delete"** button
3. Confirm the deletion
4. Tape will be removed immediately
#### Bulk Delete
1. Scroll to **"Bulk Actions"** section
2. Enter a pattern (supports wildcards):
- `CLN*` - All tapes starting with "CLN"
- `BACKUP*` - All backup tapes
- `*001` - All tapes ending with "001"
- `TEST*` - All test tapes
3. Click **"🗑️ Bulk Delete"**
4. Confirm the deletion
5. See count of deleted tapes
**⚠️ Warning:** Bulk delete is permanent and cannot be undone!
### Searching/Filtering
1. Use the search box in the **"Tape Files"** section
2. Type any part of the barcode
3. Results filter in real-time
4. Case-insensitive search
## API Reference
### Endpoints
All endpoints use `POST` method to `/mhvtl-config/api.php`
#### 1. Create Tapes
```json
POST /mhvtl-config/api.php
Content-Type: application/json
{
"action": "create_tapes",
"library": 10,
"barcode_prefix": "CLN",
"start_num": 100,
"count": 5,
"size": 2500000,
"media_type": "data",
"density": "LTO6"
}
```
**Response:**
```json
{
"success": true,
"created_count": 5,
"message": "Created 5 tape(s)"
}
```
#### 2. List Tapes
```json
POST /mhvtl-config/api.php
Content-Type: application/json
{
"action": "list_tapes"
}
```
**Response:**
```json
{
"success": true,
"tapes": [
{
"name": "CLN000100",
"size": "1.5 KB",
"modified": "2025-12-09 14:04:56"
}
]
}
```
#### 3. Delete Single Tape
```json
POST /mhvtl-config/api.php
Content-Type: application/json
{
"action": "delete_tape",
"tape_name": "CLN000100"
}
```
**Response:**
```json
{
"success": true,
"message": "Tape deleted successfully"
}
```
#### 4. Bulk Delete Tapes
```json
POST /mhvtl-config/api.php
Content-Type: application/json
{
"action": "bulk_delete_tapes",
"pattern": "CLN*"
}
```
**Response:**
```json
{
"success": true,
"deleted_count": 6,
"message": "Deleted 6 tape(s)"
}
```
## Technical Details
### File Structure
```
/opt/mhvtl/ # Tape storage directory
├── CLN000100/ # Individual tape directory
│ ├── data # Tape data file
│ ├── indx # Index file
│ └── meta # Metadata file
├── CLN000101/
└── ...
```
### Permissions
- **Directory**: `/opt/mhvtl/` - 775 (vtl:vtl)
- **Tape Dirs**: 750 (owner:vtl)
- **Tape Files**: 640 (owner:vtl)
- **Web User**: www-data (member of vtl group)
### Security Features
1. **Path Traversal Protection**
- Validates all tape paths
- Blocks `..` and `/` in patterns
- Ensures operations stay within `/opt/mhvtl/`
2. **Input Validation**
- Barcode prefix: max 6 characters
- Tape count: 1-100 limit
- Media type: whitelist validation
- Density: whitelist validation
3. **Sudo Configuration**
- Limited sudo access for www-data
- Only specific commands allowed
- No password required for allowed operations
4. **Error Handling**
- Graceful error messages
- Partial success reporting
- Detailed error logs
### Sudoers Configuration
File: `/etc/sudoers.d/mhvtl`
```bash
# Allow www-data to manage mhvtl
www-data ALL=(ALL) NOPASSWD: /bin/systemctl restart mhvtl
www-data ALL=(ALL) NOPASSWD: /bin/systemctl start mhvtl
www-data ALL=(ALL) NOPASSWD: /bin/systemctl stop mhvtl
www-data ALL=(ALL) NOPASSWD: /bin/systemctl status mhvtl
www-data ALL=(ALL) NOPASSWD: /bin/rm -rf /opt/mhvtl/*
# Same for apache (RPM-based systems)
apache ALL=(ALL) NOPASSWD: /bin/systemctl restart mhvtl
apache ALL=(ALL) NOPASSWD: /bin/systemctl start mhvtl
apache ALL=(ALL) NOPASSWD: /bin/systemctl stop mhvtl
apache ALL=(ALL) NOPASSWD: /bin/systemctl status mhvtl
apache ALL=(ALL) NOPASSWD: /bin/rm -rf /opt/mhvtl/*
```
### Command Generation
The `mktape` command is executed as:
```bash
mktape -l <library> -m <barcode> -s <size_MB> -t <type> -d <density>
```
**Example:**
```bash
mktape -l 10 -m CLN000100 -s 2500000 -t data -d LTO6
```
## Testing
### CRUD Test Results
```bash
=== CRUD TEST ===
1. CREATE: ✅ success:true
2. READ: ✅ "name":"CRUD000001", "name":"CRUD000002"
3. DELETE: ✅ success:true
4. VERIFY: ✅ Only CRUD000002 remains
```
### Performance
- **Create 1 tape**: ~1 second
- **Create 10 tapes**: ~3 seconds
- **List 100 tapes**: <1 second
- **Delete 1 tape**: ~1 second
- **Bulk delete 50 tapes**: ~3 seconds
## Troubleshooting
### Issue: "Permission denied" when creating tapes
**Solution:**
1. Check www-data is in vtl group: `groups www-data`
2. Check /opt/mhvtl permissions: `ls -ld /opt/mhvtl`
3. Restart Apache: `systemctl restart apache2`
### Issue: "Failed to delete tape"
**Solution:**
1. Check sudoers file: `cat /etc/sudoers.d/mhvtl`
2. Test sudo access: `sudo -u www-data sudo rm -rf /opt/mhvtl/TEST`
3. Check tape exists: `ls /opt/mhvtl/`
### Issue: Tapes not showing in list
**Solution:**
1. Click "🔄 Refresh List" button
2. Check browser console for errors (F12)
3. Verify API is accessible: `curl http://localhost/mhvtl-config/api.php`
### Issue: "Invalid density" error
**Solution:**
Use one of the supported densities:
- LTO5, LTO6, LTO7, LTO8, LTO9
## Best Practices
1. **Naming Convention**
- Use meaningful prefixes (BACKUP, ARCHIVE, TEST, etc.)
- Keep prefixes short (3-6 chars)
- Use consistent numbering scheme
2. **Tape Organization**
- Group tapes by purpose (prefix)
- Use sequential numbering
- Document tape usage in external system
3. **Deletion Safety**
- Always confirm before bulk delete
- Test patterns with small sets first
- Keep backups of important data
4. **Performance**
- Create tapes in batches (10-50 at a time)
- Use bulk delete for cleanup
- Regular cleanup of unused tapes
## Future Enhancements
- [ ] Edit tape properties (size, type)
- [ ] Tape usage statistics
- [ ] Export tape list to CSV
- [ ] Tape backup/restore
- [ ] Tape verification/integrity check
- [ ] Batch operations from file upload
- [ ] Tape labeling/tagging system
- [ ] Usage history/audit log
## Package Information
- **Version**: 1.0.0
- **Package Size**: 26 KB
- **Files**: 28
- **Location**: `/builder/adastra-vtl/dist/adastra-vtl-installer-1.0.0.tar.gz`
## Support
For issues or questions:
1. Check this guide first
2. Review the troubleshooting section
3. Check system logs: `journalctl -u mhvtl`
4. Check Apache logs: `/var/log/apache2/error.log`
---
**Last Updated**: December 9, 2025
**Status**: Production Ready

112
build-installer.sh Normal file
View File

@@ -0,0 +1,112 @@
#!/bin/bash
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OUTPUT_DIR="$SCRIPT_DIR/dist"
PACKAGE_NAME="adastra-vtl-installer"
VERSION="1.0.0"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
print_info() {
echo -e "${YELLOW}${NC} $1"
}
print_success() {
echo -e "${GREEN}${NC} $1"
}
print_error() {
echo -e "${RED}${NC} $1"
}
create_package() {
print_info "Creating installer package..."
rm -rf "$OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR/$PACKAGE_NAME"
print_info "Copying files..."
cp -r "$SCRIPT_DIR/web-ui" "$OUTPUT_DIR/$PACKAGE_NAME/"
cp -r "$SCRIPT_DIR/config" "$OUTPUT_DIR/$PACKAGE_NAME/" 2>/dev/null || true
cp -r "$SCRIPT_DIR/scripts" "$OUTPUT_DIR/$PACKAGE_NAME/" 2>/dev/null || true
cp -r "$SCRIPT_DIR/systemd" "$OUTPUT_DIR/$PACKAGE_NAME/" 2>/dev/null || true
cp -r "$SCRIPT_DIR/docs" "$OUTPUT_DIR/$PACKAGE_NAME/" 2>/dev/null || true
cp "$SCRIPT_DIR/install.sh" "$OUTPUT_DIR/$PACKAGE_NAME/"
cp "$SCRIPT_DIR/uninstall.sh" "$OUTPUT_DIR/$PACKAGE_NAME/"
cp "$SCRIPT_DIR/README.md" "$OUTPUT_DIR/$PACKAGE_NAME/" 2>/dev/null || true
cp "$SCRIPT_DIR/INSTALLER-README.md" "$OUTPUT_DIR/$PACKAGE_NAME/README.md" 2>/dev/null || true
chmod +x "$OUTPUT_DIR/$PACKAGE_NAME/install.sh"
chmod +x "$OUTPUT_DIR/$PACKAGE_NAME/uninstall.sh"
chmod +x "$OUTPUT_DIR/$PACKAGE_NAME/scripts"/*.sh 2>/dev/null || true
cat > "$OUTPUT_DIR/$PACKAGE_NAME/VERSION" << EOF
Adastra VTL Installer
Version: $VERSION
Build Date: $(date '+%Y-%m-%d %H:%M:%S')
Build Host: $(hostname)
EOF
print_info "Creating tarball..."
cd "$OUTPUT_DIR"
tar -czf "${PACKAGE_NAME}-${VERSION}.tar.gz" "$PACKAGE_NAME"
TARBALL_SIZE=$(du -h "${PACKAGE_NAME}-${VERSION}.tar.gz" | cut -f1)
TARBALL_PATH="$OUTPUT_DIR/${PACKAGE_NAME}-${VERSION}.tar.gz"
print_success "Package created successfully!"
echo ""
echo -e "${BLUE}╔════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ Package Build Complete ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════╝${NC}"
echo ""
echo -e "${GREEN}Package Details:${NC}"
echo -e " • Name: ${YELLOW}${PACKAGE_NAME}-${VERSION}.tar.gz${NC}"
echo -e " • Size: ${YELLOW}${TARBALL_SIZE}${NC}"
echo -e " • Location: ${YELLOW}${TARBALL_PATH}${NC}"
echo ""
echo -e "${GREEN}Contents:${NC}"
echo -e " • Web UI (mhvtl configuration interface)"
echo -e " • Installation scripts"
echo -e " • Systemd service files"
echo -e " • Configuration templates"
echo -e " • Documentation"
echo ""
echo -e "${GREEN}Supported Distributions:${NC}"
echo -e " • Debian 10+ / Ubuntu 18.04+"
echo -e " • RHEL/CentOS 7+ / Fedora 30+"
echo -e " • Rocky Linux 8+ / AlmaLinux 8+"
echo ""
echo -e "${BLUE}Quick Start:${NC}"
echo ""
echo -e "${YELLOW}1. Copy to target system:${NC}"
echo -e " scp ${TARBALL_PATH} user@server:~/"
echo ""
echo -e "${YELLOW}2. On target system:${NC}"
echo -e " tar -xzf ${PACKAGE_NAME}-${VERSION}.tar.gz"
echo -e " cd ${PACKAGE_NAME}"
echo -e " sudo ./install.sh"
echo ""
echo -e "${YELLOW}3. Access Web UI:${NC}"
echo -e " http://[SERVER-IP]/mhvtl-config"
echo ""
echo -e "${BLUE}For detailed instructions, see README.md in the package${NC}"
echo ""
}
main() {
echo -e "${BLUE}╔════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ Adastra VTL Package Builder ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════╝${NC}"
echo ""
create_package
}
main "$@"

15
config/mhvtl-sudoers Normal file
View File

@@ -0,0 +1,15 @@
# Allow www-data to restart mhvtl service without password
www-data ALL=(ALL) NOPASSWD: /bin/systemctl restart mhvtl
www-data ALL=(ALL) NOPASSWD: /bin/systemctl start mhvtl
www-data ALL=(ALL) NOPASSWD: /bin/systemctl stop mhvtl
www-data ALL=(ALL) NOPASSWD: /bin/systemctl status mhvtl
www-data ALL=(ALL) NOPASSWD: /bin/rm -rf /opt/mhvtl/*
www-data ALL=(ALL) NOPASSWD: /usr/sbin/tgtadm
# Allow apache to restart mhvtl service without password (for RPM-based systems)
apache ALL=(ALL) NOPASSWD: /bin/systemctl restart mhvtl
apache ALL=(ALL) NOPASSWD: /bin/systemctl start mhvtl
apache ALL=(ALL) NOPASSWD: /bin/systemctl stop mhvtl
apache ALL=(ALL) NOPASSWD: /bin/systemctl status mhvtl
apache ALL=(ALL) NOPASSWD: /bin/rm -rf /opt/mhvtl/*
apache ALL=(ALL) NOPASSWD: /usr/sbin/tgtadm

BIN
dist/adastra-vtl-installer-1.0.0.tar.gz vendored Normal file

Binary file not shown.

178
dist/adastra-vtl-installer/README.md vendored Normal file
View File

@@ -0,0 +1,178 @@
# Adastra VTL Installer Package
Binary installer untuk Adastra Virtual Tape Library (VTL) yang support Debian-based dan RPM-based Linux distributions.
## Supported Distributions
### Debian-based:
- Debian 10+
- Ubuntu 18.04+
- Linux Mint
- Pop!_OS
### RPM-based:
- RHEL/CentOS 7+
- Fedora 30+
- Rocky Linux 8+
- AlmaLinux 8+
## System Requirements
- Root access (sudo)
- Internet connection (untuk download mhvtl source)
- Minimum 2GB RAM
- 10GB free disk space
- Kernel headers installed
## Installation
### 1. Extract Package
```bash
tar -xzf adastra-vtl-installer-1.0.0.tar.gz
cd adastra-vtl-installer
```
### 2. Run Installer
```bash
sudo ./install.sh
```
Installer akan otomatis:
- Detect distro (Debian/Ubuntu atau RHEL/CentOS/Fedora)
- Install dependencies (Apache/httpd, PHP, build tools, dll)
- Download & compile mhvtl dari source
- Install Adastra VTL ke `/opt/adastra-vtl`
- Deploy Web UI ke `/var/www/html/mhvtl-config`
- Setup systemd service
- Configure firewall (RPM-based)
- Create user & group `vtl`
### 3. Post-Installation
Setelah instalasi selesai:
```bash
# Load mhvtl kernel modules
mhvtl-load
# Start mhvtl service
systemctl start mhvtl
# Enable on boot
systemctl enable mhvtl
# Check status
systemctl status mhvtl
```
## Web UI Access
Setelah instalasi, Web UI bisa diakses di:
```
http://[SERVER-IP]/mhvtl-config
```
Gunakan Web UI untuk:
- Configure library settings
- Add/remove drives
- Generate tape configuration
- Export device.conf
## Configuration Files
- **Main config**: `/etc/mhvtl/device.conf`
- **Library contents**: `/etc/mhvtl/library_contents.*`
- **mhvtl config**: `/etc/mhvtl/mhvtl.conf`
- **Web UI**: `/var/www/html/mhvtl-config/`
- **Install dir**: `/opt/adastra-vtl/`
## Useful Commands
```bash
# Load mhvtl modules
mhvtl-load
# Unload mhvtl modules
mhvtl-unload
# Check mhvtl status
systemctl status mhvtl
# View SCSI devices
lsscsi -g
# Restart mhvtl
systemctl restart mhvtl
# View logs
journalctl -u mhvtl -f
```
## Uninstallation
```bash
sudo ./uninstall.sh
```
Ini akan:
- Stop & disable mhvtl service
- Unload kernel modules
- Remove installed files
- Preserve config files di `/etc/mhvtl/`
## Troubleshooting
### mhvtl service tidak start
```bash
# Check if binaries exist
which vtltape vtllibrary
# Check if modules loaded
lsmod | grep mhvtl
# Try manual start
vtltape -q
vtllibrary -q
```
### Web UI tidak bisa diakses
```bash
# Check Apache/httpd status
systemctl status apache2 # Debian/Ubuntu
systemctl status httpd # RHEL/CentOS
# Check firewall (RPM-based)
firewall-cmd --list-services
# Check permissions
ls -la /var/www/html/mhvtl-config/
```
### Kernel module tidak load
```bash
# Check if kernel headers installed
dpkg -l | grep linux-headers # Debian/Ubuntu
rpm -qa | grep kernel-devel # RHEL/CentOS
# Rebuild mhvtl
cd /tmp
git clone https://github.com/markh794/mhvtl.git
cd mhvtl
make clean
make
sudo make install
```
## Support
Untuk issues dan pertanyaan, silakan buka issue di GitHub repository.
## License
See LICENSE file in the repository.

4
dist/adastra-vtl-installer/VERSION vendored Normal file
View File

@@ -0,0 +1,4 @@
Adastra VTL Installer
Version: 1.0.0
Build Date: 2025-12-09 14:54:54
Build Host: vtl-dev

View File

@@ -0,0 +1,15 @@
# Allow www-data to restart mhvtl service without password
www-data ALL=(ALL) NOPASSWD: /bin/systemctl restart mhvtl
www-data ALL=(ALL) NOPASSWD: /bin/systemctl start mhvtl
www-data ALL=(ALL) NOPASSWD: /bin/systemctl stop mhvtl
www-data ALL=(ALL) NOPASSWD: /bin/systemctl status mhvtl
www-data ALL=(ALL) NOPASSWD: /bin/rm -rf /opt/mhvtl/*
www-data ALL=(ALL) NOPASSWD: /usr/sbin/tgtadm
# Allow apache to restart mhvtl service without password (for RPM-based systems)
apache ALL=(ALL) NOPASSWD: /bin/systemctl restart mhvtl
apache ALL=(ALL) NOPASSWD: /bin/systemctl start mhvtl
apache ALL=(ALL) NOPASSWD: /bin/systemctl stop mhvtl
apache ALL=(ALL) NOPASSWD: /bin/systemctl status mhvtl
apache ALL=(ALL) NOPASSWD: /bin/rm -rf /opt/mhvtl/*
apache ALL=(ALL) NOPASSWD: /usr/sbin/tgtadm

View File

@@ -0,0 +1,17 @@
net.core.rmem_max = 134217728
net.core.wmem_max = 134217728
net.core.rmem_default = 16777216
net.core.wmem_default = 16777216
net.ipv4.tcp_rmem = 4096 87380 67108864
net.ipv4.tcp_wmem = 4096 65536 67108864
net.ipv4.tcp_congestion_control = cubic
net.ipv4.tcp_mtu_probing = 1
net.core.netdev_max_backlog = 5000
net.ipv4.tcp_no_metrics_save = 1
vm.swappiness = 10
vm.dirty_ratio = 15
vm.dirty_background_ratio = 5
kernel.sched_migration_cost_ns = 5000000
kernel.sched_autogroup_enabled = 0

View File

@@ -0,0 +1,299 @@
# VTL Linux - Architecture & Design
## Overview
VTL Linux is an opinionated Linux distribution built specifically for Virtual Tape Library operations. It combines mhvtl (virtual tape library) with iSCSI target capabilities to provide enterprise-grade tape backup infrastructure over IP networks.
## Design Philosophy
### Opinionated Choices
1. **Debian-based**: Uses Debian Bookworm for stability and long-term support
2. **Minimal footprint**: Only essential packages included
3. **Pre-configured**: Ready-to-use mhvtl and iSCSI setup out of the box
4. **Performance-tuned**: Optimized kernel parameters for tape operations
5. **Network-first**: Designed for iSCSI connectivity from day one
### Target Use Cases
- Enterprise backup infrastructure
- Backup software testing and development
- Tape library simulation
- Disaster recovery testing
- Training environments
- Cost-effective alternative to physical tape libraries
## System Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ VTL Linux Host │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Kernel Space │ │
│ │ ┌────────────────────────────────────────────────┐ │ │
│ │ │ mhvtl Kernel Module │ │ │
│ │ │ - SCSI Target Framework │ │ │
│ │ │ - Virtual Device Emulation │ │ │
│ │ └────────────────────────────────────────────────┘ │ │
│ │ ┌────────────────────────────────────────────────┐ │ │
│ │ │ SCSI Generic (sg) Driver │ │ │
│ │ └────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ User Space │ │
│ │ ┌────────────────────────────────────────────────┐ │ │
│ │ │ mhvtl Daemons │ │ │
│ │ │ - vtltape (tape drive emulation) │ │ │
│ │ │ - vtllibrary (media changer emulation) │ │ │
│ │ └────────────────────────────────────────────────┘ │ │
│ │ ┌────────────────────────────────────────────────┐ │ │
│ │ │ iSCSI Target (tgt) │ │ │
│ │ │ - Target management │ │ │
│ │ │ - LUN mapping │ │ │
│ │ │ - Authentication (CHAP) │ │ │
│ │ └────────────────────────────────────────────────┘ │ │
│ │ ┌────────────────────────────────────────────────┐ │ │
│ │ │ Storage Backend │ │ │
│ │ │ /opt/mhvtl/ (tape data files) │ │ │
│ │ └────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│ TCP/IP (iSCSI Protocol)
│ Port 3260
┌─────────────────┴─────────────────┐
│ │
┌───────▼────────┐ ┌────────▼───────┐
│ Linux Client │ │ Windows Client │
│ │ │ │
│ ┌──────────┐ │ │ ┌──────────┐ │
│ │ iSCSI │ │ │ │ iSCSI │ │
│ │Initiator │ │ │ │Initiator │ │
│ └──────────┘ │ │ └──────────┘ │
│ ┌──────────┐ │ │ ┌──────────┐ │
│ │ Backup │ │ │ │ Backup │ │
│ │ Software │ │ │ │ Software │ │
│ │ (Bacula, │ │ │ │ (Veeam, │ │
│ │ Amanda) │ │ │ │ Backup │ │
│ └──────────┘ │ │ │ Exec) │ │
└────────────────┘ │ └──────────┘ │
└────────────────┘
```
## Component Details
### mhvtl (Virtual Tape Library)
**Purpose**: Emulates physical tape drives and media changers
**Components**:
- Kernel module: Provides SCSI target framework
- vtltape daemon: Emulates tape drive behavior
- vtllibrary daemon: Emulates robotic media changer
- Configuration files: Define virtual devices and media
**Default Configuration**:
- 1x STK L700 library (media changer)
- 4x IBM LTO-5/6 tape drives
- 20x LTO-5 tape cartridges
- Compression enabled (LZO algorithm)
**Storage**:
- Tape data stored as files in `/opt/mhvtl/`
- Each tape is a separate file
- Supports multiple tape formats (LTO-3 through LTO-8)
### iSCSI Target (tgt)
**Purpose**: Exports SCSI devices over IP network
**Features**:
- Multi-target support
- CHAP authentication
- Access control lists
- Performance optimization
**Configuration**:
- Exports mhvtl SCSI devices as iSCSI LUNs
- Separate targets for each tape drive
- Dedicated target for media changer
- Configurable authentication
### Network Layer
**Protocol**: iSCSI (SCSI over TCP/IP)
**Port**: 3260 (standard iSCSI port)
**Authentication**: CHAP (Challenge-Handshake Authentication Protocol)
**Benefits**:
- No physical tape hardware required
- Remote access over LAN/WAN
- Multiple simultaneous clients
- Standard protocol support
## Data Flow
### Write Operation (Backup)
1. Backup software on client initiates write to tape
2. iSCSI initiator sends SCSI commands over network
3. iSCSI target receives commands on port 3260
4. Commands forwarded to mhvtl SCSI device
5. vtltape daemon processes write commands
6. Data compressed (if enabled) and written to file in `/opt/mhvtl/`
7. Acknowledgment sent back through iSCSI to client
### Read Operation (Restore)
1. Backup software requests tape mount
2. iSCSI sends media changer commands
3. vtllibrary daemon simulates robotic arm movement
4. Virtual tape "loaded" into virtual drive
5. Read commands processed by vtltape
6. Data decompressed and sent via iSCSI to client
## Performance Considerations
### Optimizations
1. **Kernel Parameters**:
- Increased network buffers
- TCP tuning for throughput
- Reduced swappiness
- I/O scheduler optimization
2. **Compression**:
- LZO compression (fast, good ratio)
- Configurable per drive
- Typical 3:1 compression ratio
3. **Network**:
- Jumbo frames support
- TCP window scaling
- Congestion control tuning
### Bottlenecks
- Network bandwidth (1Gbps recommended minimum)
- Disk I/O for tape storage
- CPU for compression/decompression
- Memory for buffering
## Security
### Authentication
- CHAP authentication for iSCSI
- Username/password per target
- Configurable initiator ACLs
### Network Security
- Firewall rules (port 3260)
- Optional VPN/IPsec for WAN
- Network segmentation recommended
### Access Control
- User permissions on tape storage
- Systemd service isolation
- SELinux/AppArmor support (optional)
## Scalability
### Vertical Scaling
- Add more virtual drives (up to 16 per library)
- Increase tape media count
- Larger storage backend
- More CPU/RAM for compression
### Horizontal Scaling
- Multiple VTL instances
- Load balancing across servers
- Distributed storage backend
- High availability clustering (future)
## Monitoring & Management
### System Monitoring
- systemd service status
- SCSI device enumeration
- iSCSI target status
- Storage utilization
### Tools Provided
- `vtl-status`: Comprehensive system status
- `lsscsi`: SCSI device listing
- `mtx`: Media changer control
- `tgt-admin`: iSCSI target management
### Logging
- systemd journal for all services
- mhvtl debug logging (configurable)
- iSCSI connection logs
- Kernel messages for SCSI events
## Future Enhancements
### Planned Features
- Web-based management interface
- Automated tape rotation policies
- Replication to cloud storage
- High availability clustering
- Performance metrics dashboard
- Tape encryption support
- Multi-tenancy support
### Integration Opportunities
- Prometheus metrics export
- Grafana dashboards
- Ansible playbooks
- Docker containerization
- Kubernetes operators
## Comparison with Physical Tape
### Advantages
- No hardware costs
- Instant provisioning
- Easy scaling
- Remote management
- No mechanical failures
- Faster seeks
- Snapshot/backup capability
### Limitations
- Not suitable for long-term archival (use real tape)
- Dependent on disk reliability
- Network latency vs. direct attach
- No physical off-site storage
- Software emulation overhead
## Best Practices
1. **Storage**: Use dedicated disk/partition for `/opt/mhvtl/`
2. **Network**: Dedicated network interface for iSCSI traffic
3. **Backup**: Regular backup of VTL configuration and metadata
4. **Monitoring**: Set up alerts for disk space and service status
5. **Security**: Change default passwords immediately
6. **Testing**: Verify backup/restore operations regularly
7. **Documentation**: Maintain inventory of virtual tapes and contents
## References
- mhvtl project: https://github.com/markh794/mhvtl
- iSCSI specification: RFC 3720
- SCSI Architecture Model: ANSI INCITS
- Linux SCSI Target Framework documentation

View File

@@ -0,0 +1,408 @@
# VTL Linux - Configuration Examples
## mhvtl Device Configuration
### Basic LTO-5 Library Setup
```conf
VERSION: 5
Library: 10 CHANNEL: 00 TARGET: 00 LUN: 00
Vendor identification: STK
Product identification: L700
Unit serial number: XYZZY_A
NAA: 10:22:33:44:ab:cd:ef:00
Home directory: /opt/mhvtl
Backoff: 400
Drive: 00 CHANNEL: 00 TARGET: 01 LUN: 00
Library ID: 10 Slot: 01
Vendor identification: IBM
Product identification: ULT3580-TD5
Unit serial number: XYZZY_A1
NAA: 10:22:33:44:ab:cd:ef:01
Compression: factor 3 enabled 1
Compression type: lzo
Backoff: 400
```
### Multi-Drive LTO-6/7/8 Setup
```conf
VERSION: 5
Library: 20 CHANNEL: 00 TARGET: 00 LUN: 00
Vendor identification: IBM
Product identification: 03584L32
Unit serial number: XYZZY_B
NAA: 20:22:33:44:ab:cd:ef:00
Home directory: /opt/mhvtl
Backoff: 400
Drive: 10 CHANNEL: 00 TARGET: 01 LUN: 00
Library ID: 20 Slot: 01
Vendor identification: IBM
Product identification: ULT3580-TD6
Unit serial number: XYZZY_B1
NAA: 20:22:33:44:ab:cd:ef:01
Compression: factor 3 enabled 1
Compression type: lzo
Backoff: 400
Drive: 11 CHANNEL: 00 TARGET: 02 LUN: 00
Library ID: 20 Slot: 02
Vendor identification: IBM
Product identification: ULT3580-TD7
Unit serial number: XYZZY_B2
NAA: 20:22:33:44:ab:cd:ef:02
Compression: factor 3 enabled 1
Compression type: lzo
Backoff: 400
Drive: 12 CHANNEL: 00 TARGET: 03 LUN: 00
Library ID: 20 Slot: 03
Vendor identification: IBM
Product identification: ULT3580-TD8
Unit serial number: XYZZY_B3
NAA: 20:22:33:44:ab:cd:ef:03
Compression: factor 3 enabled 1
Compression type: lzo
Backoff: 400
```
## iSCSI Target Configuration
### Basic Target with CHAP Authentication
```conf
<target iqn.2024-01.com.vtl-linux:vtl.drive0>
backing-store /dev/sg1
initiator-address ALL
incominguser vtl-user vtl-password
write-cache on
</target>
```
### Target with IP Restrictions
```conf
<target iqn.2024-01.com.vtl-linux:vtl.drive0>
backing-store /dev/sg1
initiator-address 192.168.1.0/24
initiator-address 10.0.0.50
incominguser backup-server secure-password-here
write-cache on
</target>
```
### Multiple Targets for Different Clients
```conf
<target iqn.2024-01.com.vtl-linux:vtl.client1>
backing-store /dev/sg1
initiator-address 192.168.1.100
incominguser client1 password1
write-cache on
</target>
<target iqn.2024-01.com.vtl-linux:vtl.client2>
backing-store /dev/sg2
initiator-address 192.168.1.101
incominguser client2 password2
write-cache on
</target>
<target iqn.2024-01.com.vtl-linux:vtl.changer>
backing-store /dev/sg0
initiator-address 192.168.1.0/24
incominguser vtl-admin admin-password
device-type changer
</target>
```
### Target with Mutual CHAP
```conf
<target iqn.2024-01.com.vtl-linux:vtl.secure>
backing-store /dev/sg1
initiator-address 192.168.1.100
incominguser vtl-user vtl-password
outgoinguser initiator-user initiator-password
write-cache on
</target>
```
## Kernel Tuning
### High-Performance Network Configuration
```conf
net.core.rmem_max = 268435456
net.core.wmem_max = 268435456
net.core.rmem_default = 33554432
net.core.wmem_default = 33554432
net.ipv4.tcp_rmem = 4096 87380 134217728
net.ipv4.tcp_wmem = 4096 65536 134217728
net.ipv4.tcp_congestion_control = bbr
net.ipv4.tcp_mtu_probing = 1
net.core.netdev_max_backlog = 10000
net.ipv4.tcp_no_metrics_save = 1
net.ipv4.tcp_timestamps = 1
net.ipv4.tcp_sack = 1
net.ipv4.tcp_window_scaling = 1
net.core.default_qdisc = fq
```
### Storage-Optimized Configuration
```conf
vm.swappiness = 1
vm.dirty_ratio = 10
vm.dirty_background_ratio = 3
vm.vfs_cache_pressure = 50
kernel.sched_migration_cost_ns = 5000000
kernel.sched_autogroup_enabled = 0
```
## Backup Software Integration
### Bacula Configuration
```conf
Autochanger {
Name = VTL-Library
Device = Drive-0, Drive-1, Drive-2, Drive-3
Changer Command = "/usr/lib/bacula/scripts/mtx-changer %c %o %S %a %d"
Changer Device = /dev/sg0
}
Device {
Name = Drive-0
Media Type = LTO-5
Archive Device = /dev/nst0
AutomaticMount = yes
AlwaysOpen = yes
RemovableMedia = yes
RandomAccess = no
AutoChanger = yes
Drive Index = 0
Maximum Spool Size = 10G
Spool Directory = /var/spool/bacula
}
Device {
Name = Drive-1
Media Type = LTO-5
Archive Device = /dev/nst1
AutomaticMount = yes
AlwaysOpen = yes
RemovableMedia = yes
RandomAccess = no
AutoChanger = yes
Drive Index = 1
Maximum Spool Size = 10G
Spool Directory = /var/spool/bacula
}
```
### Amanda Configuration
```conf
tapedev "chg-robot:/dev/sg0"
tpchanger "chg-robot"
changerfile "/var/lib/amanda/vtl/changer"
changerdev "/dev/sg0"
tapetype LTO-5
define tapetype LTO-5 {
comment "LTO-5 Virtual Tape"
length 1500000 mbytes
filemark 0 kbytes
speed 140000 kps
}
labelstr "^VTL-[0-9][0-9]*$"
autolabel "VTL-%%%" EMPTY VOLUME_ERROR
```
### Veritas Backup Exec (Windows)
1. Configure iSCSI initiator to connect to VTL server
2. In Backup Exec, go to Storage → Configure Storage
3. Select "Tape Drive" → "Detect and configure"
4. Backup Exec will auto-detect the tape library
5. Configure media sets and backup jobs
## Network Configuration Examples
### Static IP Configuration (NetworkManager)
```bash
nmcli con add type ethernet con-name vtl-network ifname eth0 \
ipv4.addresses 192.168.1.100/24 \
ipv4.gateway 192.168.1.1 \
ipv4.dns "8.8.8.8,8.8.4.4" \
ipv4.method manual
nmcli con up vtl-network
```
### Bonded Network Interface
```bash
nmcli con add type bond con-name bond0 ifname bond0 mode active-backup
nmcli con add type ethernet con-name bond0-slave1 ifname eth0 master bond0
nmcli con add type ethernet con-name bond0-slave2 ifname eth1 master bond0
nmcli con mod bond0 ipv4.addresses 192.168.1.100/24 \
ipv4.gateway 192.168.1.1 \
ipv4.method manual
nmcli con up bond0
```
### VLAN Configuration
```bash
nmcli con add type vlan con-name vlan100 ifname eth0.100 dev eth0 id 100
nmcli con mod vlan100 ipv4.addresses 192.168.100.100/24 \
ipv4.method manual
nmcli con up vlan100
```
## Firewall Configuration
### UFW (Ubuntu/Debian)
```bash
ufw allow from 192.168.1.0/24 to any port 3260 proto tcp
ufw allow 22/tcp
ufw enable
```
### firewalld (RHEL/CentOS)
```bash
firewall-cmd --permanent --add-port=3260/tcp
firewall-cmd --permanent --add-service=ssh
firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="192.168.1.0/24" port port="3260" protocol="tcp" accept'
firewall-cmd --reload
```
### iptables
```bash
iptables -A INPUT -p tcp -s 192.168.1.0/24 --dport 3260 -j ACCEPT
iptables -A INPUT -p tcp --dport 22 -j ACCEPT
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A INPUT -j DROP
iptables-save > /etc/iptables/rules.v4
```
## Monitoring Scripts
### Tape Usage Monitor
```bash
#!/bin/bash
MHVTL_DIR="/opt/mhvtl"
THRESHOLD=80
usage=$(df -h "$MHVTL_DIR" | awk 'NR==2 {print $5}' | sed 's/%//')
if [ "$usage" -gt "$THRESHOLD" ]; then
echo "WARNING: VTL storage usage at ${usage}%"
echo "Consider adding more disk space or removing old tapes"
fi
echo "Current tape inventory:"
ls -lh "$MHVTL_DIR"/*.data 2>/dev/null | wc -l
```
### iSCSI Connection Monitor
```bash
#!/bin/bash
echo "Active iSCSI connections:"
netstat -tn | grep :3260 | grep ESTABLISHED | wc -l
echo ""
echo "Connection details:"
netstat -tn | grep :3260 | grep ESTABLISHED
```
## Systemd Service Customization
### Custom mhvtl Service with Resource Limits
```ini
[Unit]
Description=mhvtl Virtual Tape Library
After=network.target
[Service]
Type=forking
ExecStartPre=/sbin/modprobe mhvtl
ExecStart=/usr/bin/vtltape
ExecStart=/usr/bin/vtllibrary
ExecStop=/usr/bin/killall vtltape vtllibrary
Restart=on-failure
RestartSec=5s
CPUQuota=50%
MemoryLimit=2G
IOWeight=500
[Install]
WantedBy=multi-user.target
```
### Auto-restart on Failure
```ini
[Service]
Restart=always
RestartSec=10s
StartLimitInterval=200
StartLimitBurst=5
```
## Maintenance Scripts
### Tape Cleanup Script
```bash
#!/bin/bash
MHVTL_DIR="/opt/mhvtl"
DAYS_OLD=90
echo "Removing tapes older than $DAYS_OLD days..."
find "$MHVTL_DIR" -name "*.data" -mtime +$DAYS_OLD -delete
echo "Remaining tapes:"
ls -lh "$MHVTL_DIR"/*.data 2>/dev/null | wc -l
```
### Configuration Backup Script
```bash
#!/bin/bash
BACKUP_DIR="/backup/vtl-config"
DATE=$(date +%Y%m%d-%H%M%S)
mkdir -p "$BACKUP_DIR"
tar -czf "$BACKUP_DIR/vtl-config-$DATE.tar.gz" \
/etc/mhvtl/ \
/etc/tgt/conf.d/ \
/etc/sysctl.d/99-vtl.conf \
/etc/systemd/system/mhvtl.service
echo "Backup saved to: $BACKUP_DIR/vtl-config-$DATE.tar.gz"
find "$BACKUP_DIR" -name "vtl-config-*.tar.gz" -mtime +30 -delete
```

View File

@@ -0,0 +1,276 @@
# VTL Linux - Installation Guide
## Prerequisites
- x86_64 compatible hardware
- Minimum 2GB RAM (4GB+ recommended)
- 50GB+ storage for tape media
- Network interface card
- USB drive or CD/DVD for installation media
## Installation Methods
### Method 1: Live Boot (Testing)
1. Write ISO to USB drive:
```bash
dd if=VTL-Linux-1.0-x86_64.iso of=/dev/sdX bs=4M status=progress
sync
```
2. Boot from USB drive
3. System will boot into live environment with VTL services
### Method 2: Full Installation
1. Boot from ISO
2. Select "Install VTL Linux to Disk" from boot menu
3. Follow installation prompts:
- Select target disk
- Configure network
- Set hostname
- Create user account
4. Reboot after installation
## Post-Installation Configuration
### 1. Network Setup
Configure static IP (recommended for iSCSI):
```bash
sudo nmcli con mod "Wired connection 1" \
ipv4.addresses 192.168.1.100/24 \
ipv4.gateway 192.168.1.1 \
ipv4.dns "8.8.8.8 8.8.4.4" \
ipv4.method manual
sudo nmcli con up "Wired connection 1"
```
### 2. Change Default Passwords
```bash
sudo passwd root
sudo passwd vtladmin
```
### 3. Configure mhvtl
Edit device configuration:
```bash
sudo vim /etc/mhvtl/device.conf
```
Restart mhvtl service:
```bash
sudo systemctl restart mhvtl
```
### 4. Configure iSCSI Targets
Edit target configuration:
```bash
sudo vim /etc/tgt/conf.d/vtl-targets.conf
```
Update credentials:
```bash
incominguser <username> <password>
```
Restart tgt service:
```bash
sudo systemctl restart tgt
```
### 5. Verify Installation
Check system status:
```bash
vtl-status
```
List SCSI devices:
```bash
lsscsi -g
```
Check library status:
```bash
mtx -f /dev/sg0 status
```
View iSCSI targets:
```bash
tgt-admin --show
```
## Client Configuration
### Linux Client
1. Install iSCSI initiator:
```bash
sudo apt-get install open-iscsi
```
2. Discover targets:
```bash
sudo iscsiadm -m discovery -t st -p <VTL_IP>:3260
```
3. Login to target:
```bash
sudo iscsiadm -m node --login
```
4. Configure CHAP authentication (if required):
```bash
sudo iscsiadm -m node -T <target_iqn> -p <VTL_IP>:3260 \
--op=update --name node.session.auth.authmethod --value=CHAP
sudo iscsiadm -m node -T <target_iqn> -p <VTL_IP>:3260 \
--op=update --name node.session.auth.username --value=vtl-user
sudo iscsiadm -m node -T <target_iqn> -p <VTL_IP>:3260 \
--op=update --name node.session.auth.password --value=vtl-password
```
5. Verify connection:
```bash
lsscsi
```
### Windows Client
1. Open iSCSI Initiator (Control Panel → Administrative Tools)
2. Go to Discovery tab, click "Discover Portal"
3. Enter VTL server IP address and port 3260
4. Go to Targets tab, select discovered target
5. Click "Connect"
6. For CHAP authentication:
- Click "Advanced"
- Enable CHAP login
- Enter username: vtl-user
- Enter password: vtl-password
7. Verify in Device Manager under "Tape drives"
## Backup Software Configuration
### Bacula
```bash
Device {
Name = VTL-Drive-0
Media Type = LTO-5
Archive Device = /dev/nst0
AutomaticMount = yes
AlwaysOpen = yes
RemovableMedia = yes
RandomAccess = no
AutoChanger = yes
}
Autochanger {
Name = VTL-Library
Device = VTL-Drive-0, VTL-Drive-1, VTL-Drive-2, VTL-Drive-3
Changer Command = "/usr/lib/bacula/scripts/mtx-changer %c %o %S %a %d"
Changer Device = /dev/sg0
}
```
### Amanda
```bash
tapedev "chg-robot:/dev/sg0"
tpchanger "chg-robot"
changerfile "/var/lib/amanda/vtl/changer"
```
### Veeam (Windows)
1. Add tape server in Veeam console
2. Rescan tape infrastructure
3. VTL devices should appear automatically
4. Configure media pools and backup jobs
## Troubleshooting
### mhvtl not starting
```bash
sudo modprobe mhvtl
sudo systemctl status mhvtl
sudo journalctl -u mhvtl -n 50
```
### iSCSI connection issues
```bash
sudo systemctl status tgt
sudo tgt-admin --show
sudo netstat -tlnp | grep 3260
```
### SCSI devices not visible
```bash
sudo modprobe sg
lsmod | grep mhvtl
dmesg | grep -i scsi
```
### Performance issues
Check system resources:
```bash
htop
iostat -x 1
iotop
```
Adjust kernel parameters in `/etc/sysctl.d/99-vtl.conf`
## Maintenance
### Adding tape media
```bash
sudo /usr/bin/mktape -l 10 -s 100 -m /opt/mhvtl -t LTO5 -d 10
sudo systemctl restart mhvtl
```
### Backup configuration
```bash
sudo tar -czf vtl-config-backup.tar.gz \
/etc/mhvtl/ \
/etc/tgt/conf.d/ \
/etc/sysctl.d/99-vtl.conf
```
### Update system
```bash
sudo apt-get update
sudo apt-get upgrade
sudo reboot
```
## Support
For issues and questions:
- Check logs: `journalctl -xe`
- Review documentation in `/vtl/docs/`
- mhvtl documentation: https://github.com/markh794/mhvtl

348
dist/adastra-vtl-installer/install.sh vendored Executable file
View File

@@ -0,0 +1,348 @@
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
INSTALL_DIR="/opt/adastra-vtl"
WEB_DIR="/var/www/html/mhvtl-config"
SYSTEMD_DIR="/etc/systemd/system"
CONFIG_DIR="/etc/mhvtl"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
print_header() {
echo -e "${BLUE}╔════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ Adastra VTL Installer v1.0 ║${NC}"
echo -e "${BLUE}║ Virtual Tape Library System ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════╝${NC}"
echo ""
}
print_success() {
echo -e "${GREEN}${NC} $1"
}
print_error() {
echo -e "${RED}${NC} $1"
}
print_info() {
echo -e "${YELLOW}${NC} $1"
}
check_root() {
if [ "$EUID" -ne 0 ]; then
print_error "Please run as root or with sudo"
exit 1
fi
}
detect_distro() {
if [ -f /etc/os-release ]; then
. /etc/os-release
DISTRO=$ID
VERSION=$VERSION_ID
elif [ -f /etc/redhat-release ]; then
DISTRO="rhel"
elif [ -f /etc/debian_version ]; then
DISTRO="debian"
else
print_error "Unable to detect Linux distribution"
exit 1
fi
print_info "Detected: $DISTRO $VERSION"
}
install_dependencies_debian() {
print_info "Installing dependencies for Debian/Ubuntu..."
apt-get update -qq
DEBIAN_PACKAGES=(
"build-essential"
"git"
"zlib1g-dev"
"lsscsi"
"mt-st"
"mtx"
"lsof"
"sg3-utils"
"apache2"
"php"
"libapache2-mod-php"
)
apt-get install -y "${DEBIAN_PACKAGES[@]}"
systemctl enable apache2
systemctl start apache2
print_success "Dependencies installed (Debian/Ubuntu)"
}
install_dependencies_rpm() {
print_info "Installing dependencies for RHEL/CentOS/Fedora..."
if command -v dnf &> /dev/null; then
PKG_MGR="dnf"
else
PKG_MGR="yum"
fi
RPM_PACKAGES=(
"gcc"
"gcc-c++"
"make"
"git"
"zlib-devel"
"lsscsi"
"mt-st"
"mtx"
"lsof"
"sg3_utils"
"httpd"
"php"
)
$PKG_MGR install -y "${RPM_PACKAGES[@]}"
systemctl enable httpd
systemctl start httpd
if command -v firewall-cmd &> /dev/null; then
firewall-cmd --permanent --add-service=http
firewall-cmd --reload
fi
if command -v setenforce &> /dev/null; then
setenforce 0 || true
sed -i 's/^SELINUX=enforcing/SELINUX=permissive/' /etc/selinux/config 2>/dev/null || true
fi
print_success "Dependencies installed (RHEL/CentOS/Fedora)"
}
install_mhvtl() {
print_info "Installing mhvtl from source..."
cd /tmp
if [ -d "mhvtl" ]; then
rm -rf mhvtl
fi
git clone https://github.com/markh794/mhvtl.git
cd mhvtl
make
make install
if [ ! -d "$CONFIG_DIR" ]; then
mkdir -p "$CONFIG_DIR"
fi
if [ -f "kernel/mhvtl.ko" ]; then
cp kernel/*.ko /lib/modules/$(uname -r)/kernel/drivers/scsi/ 2>/dev/null || true
depmod -a
fi
cd /tmp
rm -rf mhvtl
print_success "mhvtl installed"
}
install_adastra_vtl() {
print_info "Installing Adastra VTL..."
if [ -d "$INSTALL_DIR" ]; then
print_info "Removing old installation..."
rm -rf "$INSTALL_DIR"
fi
mkdir -p "$INSTALL_DIR"
print_info "Copying scripts..."
if [ -d "$SCRIPT_DIR/scripts" ]; then
cp -r "$SCRIPT_DIR/scripts" "$INSTALL_DIR/"
chmod +x "$INSTALL_DIR/scripts"/*.sh
print_success "Scripts copied"
else
print_error "Scripts directory not found: $SCRIPT_DIR/scripts"
fi
print_info "Copying documentation..."
if [ -d "$SCRIPT_DIR/docs" ]; then
cp -r "$SCRIPT_DIR/docs" "$INSTALL_DIR/"
print_success "Documentation copied"
fi
if [ -f "$SCRIPT_DIR/README.md" ]; then
cp "$SCRIPT_DIR/README.md" "$INSTALL_DIR/"
fi
print_info "Copying configuration templates..."
if [ -d "$SCRIPT_DIR/config" ]; then
mkdir -p "$CONFIG_DIR"
for file in "$SCRIPT_DIR/config"/*; do
if [ -f "$file" ]; then
filename=$(basename "$file")
if [ ! -f "$CONFIG_DIR/$filename" ]; then
cp "$file" "$CONFIG_DIR/"
fi
fi
done
print_success "Configuration templates copied"
fi
print_info "Deploying Web UI..."
if [ -d "$SCRIPT_DIR/web-ui" ]; then
mkdir -p "$WEB_DIR"
cp -r "$SCRIPT_DIR/web-ui"/* "$WEB_DIR/"
if [ "$DISTRO" = "debian" ] || [ "$DISTRO" = "ubuntu" ]; then
chown -R www-data:www-data "$WEB_DIR"
else
chown -R apache:apache "$WEB_DIR"
fi
chmod -R 755 "$WEB_DIR"
print_success "Web UI deployed to $WEB_DIR"
else
print_error "Web UI directory not found: $SCRIPT_DIR/web-ui"
fi
print_info "Installing systemd services..."
if [ -d "$SCRIPT_DIR/systemd" ]; then
cp "$SCRIPT_DIR/systemd"/*.service "$SYSTEMD_DIR/"
systemctl daemon-reload
print_success "Systemd services installed"
else
print_error "Systemd directory not found: $SCRIPT_DIR/systemd"
fi
print_info "Configuring sudoers for web UI..."
if [ -f "$SCRIPT_DIR/config/mhvtl-sudoers" ]; then
cp "$SCRIPT_DIR/config/mhvtl-sudoers" /etc/sudoers.d/mhvtl
chmod 440 /etc/sudoers.d/mhvtl
print_success "Sudoers configured"
fi
print_success "Adastra VTL installed to $INSTALL_DIR"
}
configure_system() {
print_info "Configuring system..."
if ! grep -q "^vtl:" /etc/group; then
groupadd vtl
fi
if ! id -u vtl &>/dev/null; then
useradd -r -g vtl -s /bin/bash -d /var/lib/mhvtl vtl
fi
mkdir -p /var/lib/mhvtl
chown -R vtl:vtl /var/lib/mhvtl
mkdir -p /opt/mhvtl
chown -R vtl:vtl /opt/mhvtl
chmod 775 /opt/mhvtl
mkdir -p "$CONFIG_DIR/backups"
chown -R vtl:vtl "$CONFIG_DIR"
chmod 775 "$CONFIG_DIR"
chmod 775 "$CONFIG_DIR/backups"
chmod 664 "$CONFIG_DIR"/*.conf 2>/dev/null || true
if [ "$DISTRO" = "debian" ] || [ "$DISTRO" = "ubuntu" ]; then
usermod -a -G vtl www-data
systemctl restart apache2 2>/dev/null || true
else
usermod -a -G vtl apache
systemctl restart httpd 2>/dev/null || true
fi
if [ -f "$INSTALL_DIR/scripts/load-mhvtl.sh" ]; then
ln -sf "$INSTALL_DIR/scripts/load-mhvtl.sh" /usr/local/bin/mhvtl-load
fi
if [ -f "$INSTALL_DIR/scripts/unload-mhvtl.sh" ]; then
ln -sf "$INSTALL_DIR/scripts/unload-mhvtl.sh" /usr/local/bin/mhvtl-unload
fi
print_success "System configured"
}
print_completion() {
echo ""
echo -e "${GREEN}╔════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ Installation Complete! ║${NC}"
echo -e "${GREEN}╚════════════════════════════════════════╝${NC}"
echo ""
echo -e "${BLUE}Installation Details:${NC}"
echo -e " • Install directory: ${YELLOW}$INSTALL_DIR${NC}"
echo -e " • Web UI: ${YELLOW}http://$(hostname -I | awk '{print $1}')/mhvtl-config${NC}"
echo -e " • Config directory: ${YELLOW}$CONFIG_DIR${NC}"
echo ""
echo -e "${BLUE}Quick Start:${NC}"
echo -e " 1. Load mhvtl kernel module:"
echo -e " ${YELLOW}mhvtl-load${NC}"
echo ""
echo -e " 2. Configure via Web UI or edit:"
echo -e " ${YELLOW}$CONFIG_DIR/device.conf${NC}"
echo ""
echo -e " 3. Start mhvtl service:"
echo -e " ${YELLOW}systemctl start mhvtl${NC}"
echo ""
echo -e " 4. Enable on boot:"
echo -e " ${YELLOW}systemctl enable mhvtl${NC}"
echo ""
echo -e "${BLUE}Useful Commands:${NC}"
echo -e " • Load modules: ${YELLOW}mhvtl-load${NC}"
echo -e " • Unload modules: ${YELLOW}mhvtl-unload${NC}"
echo -e " • Check status: ${YELLOW}systemctl status mhvtl${NC}"
echo -e " • View devices: ${YELLOW}lsscsi -g${NC}"
echo ""
}
main() {
print_header
check_root
detect_distro
case $DISTRO in
ubuntu|debian|linuxmint|pop)
install_dependencies_debian
;;
rhel|centos|fedora|rocky|almalinux)
install_dependencies_rpm
;;
*)
print_error "Unsupported distribution: $DISTRO"
print_info "Supported: Debian, Ubuntu, RHEL, CentOS, Fedora, Rocky, AlmaLinux"
exit 1
;;
esac
install_mhvtl
install_adastra_vtl
configure_system
print_info "Fixing mhvtl configuration permissions..."
chmod 664 "$CONFIG_DIR"/*.conf 2>/dev/null || true
print_success "Permissions fixed"
print_completion
}
main "$@"

View File

@@ -0,0 +1,104 @@
#!/bin/bash
set -e
echo "=========================================="
echo " iSCSI Target Configuration Script"
echo "=========================================="
echo ""
if [ "$EUID" -ne 0 ]; then
echo "Error: This script must be run as root"
exit 1
fi
TGT_CONFIG_DIR="/etc/tgt/conf.d"
ISCSI_IQN_BASE="iqn.2024-01.com.vtl-linux"
echo "[1/4] Installing iSCSI target software..."
apt-get update
apt-get install -y tgt
echo "[2/4] Configuring iSCSI targets..."
mkdir -p "$TGT_CONFIG_DIR"
cat > "$TGT_CONFIG_DIR/vtl-targets.conf" << 'EOF'
<target iqn.2024-01.com.vtl-linux:vtl.lun0>
backing-store /dev/sg1
initiator-address ALL
incominguser vtl-user vtl-password
write-cache on
</target>
<target iqn.2024-01.com.vtl-linux:vtl.lun1>
backing-store /dev/sg2
initiator-address ALL
incominguser vtl-user vtl-password
write-cache on
</target>
<target iqn.2024-01.com.vtl-linux:vtl.lun2>
backing-store /dev/sg3
initiator-address ALL
incominguser vtl-user vtl-password
write-cache on
</target>
<target iqn.2024-01.com.vtl-linux:vtl.lun3>
backing-store /dev/sg4
initiator-address ALL
incominguser vtl-user vtl-password
write-cache on
</target>
<target iqn.2024-01.com.vtl-linux:vtl.changer>
backing-store /dev/sg0
initiator-address ALL
incominguser vtl-user vtl-password
device-type changer
</target>
EOF
echo "[3/4] Configuring firewall..."
if command -v ufw &> /dev/null; then
ufw allow 3260/tcp
ufw reload
elif command -v firewall-cmd &> /dev/null; then
firewall-cmd --permanent --add-port=3260/tcp
firewall-cmd --reload
else
iptables -A INPUT -p tcp --dport 3260 -j ACCEPT
iptables-save > /etc/iptables/rules.v4
fi
echo "[4/4] Starting iSCSI target service..."
systemctl enable tgt
systemctl restart tgt
sleep 2
echo ""
echo "=========================================="
echo " iSCSI Target Configuration Complete!"
echo "=========================================="
echo ""
echo "Available targets:"
tgt-admin --show
echo ""
echo "Connection information:"
echo " - Port: 3260"
echo " - IQN Base: $ISCSI_IQN_BASE"
echo " - Username: vtl-user"
echo " - Password: vtl-password"
echo ""
echo "Client connection examples:"
echo ""
echo "Linux:"
echo " iscsiadm -m discovery -t st -p <SERVER_IP>:3260"
echo " iscsiadm -m node --login"
echo ""
echo "Windows:"
echo " iscsicli QAddTargetPortal <SERVER_IP>"
echo " iscsicli ListTargets"
echo " iscsicli LoginTarget <target_name> T * * * * * * * * * * * * * * * <username> <password>"
echo ""

View File

@@ -0,0 +1,133 @@
#!/bin/bash
set -e
echo "=========================================="
echo " mhvtl Installation Script"
echo "=========================================="
echo ""
if [ "$EUID" -ne 0 ]; then
echo "Error: This script must be run as root"
exit 1
fi
MHVTL_VERSION="1.6-7"
MHVTL_DIR="/opt/mhvtl"
MHVTL_CONFIG="/etc/mhvtl"
echo "[1/5] Installing build dependencies..."
apt-get update
apt-get install -y \
build-essential \
git \
zlib1g-dev \
libibverbs-dev \
libconfig-dev \
libssl-dev \
uuid-dev \
linux-headers-$(uname -r) \
mt-st \
mtx \
lsscsi \
sg3-utils
echo "[2/5] Downloading mhvtl source..."
cd /tmp
if [ -d "mhvtl" ]; then
rm -rf mhvtl
fi
git clone https://github.com/markh794/mhvtl.git
cd mhvtl
echo "[3/5] Building mhvtl..."
make
echo "[4/5] Installing mhvtl..."
make install
echo "[5/5] Configuring mhvtl..."
mkdir -p "$MHVTL_DIR"
mkdir -p "$MHVTL_CONFIG"
if [ ! -f "$MHVTL_CONFIG/device.conf" ]; then
cat > "$MHVTL_CONFIG/device.conf" << 'EOF'
VERSION: 5
Library: 10 CHANNEL: 00 TARGET: 00 LUN: 00
Vendor identification: STK
Product identification: L700
Unit serial number: XYZZY_A
NAA: 10:22:33:44:ab:cd:ef:00
Home directory: /opt/mhvtl
Backoff: 400
Drive: 00 CHANNEL: 00 TARGET: 01 LUN: 00
Library ID: 10 Slot: 01
Vendor identification: IBM
Product identification: ULT3580-TD5
Unit serial number: XYZZY_A1
NAA: 10:22:33:44:ab:cd:ef:01
Compression: factor 3 enabled 1
Compression type: lzo
Backoff: 400
Drive: 01 CHANNEL: 00 TARGET: 02 LUN: 00
Library ID: 10 Slot: 02
Vendor identification: IBM
Product identification: ULT3580-TD5
Unit serial number: XYZZY_A2
NAA: 10:22:33:44:ab:cd:ef:02
Compression: factor 3 enabled 1
Compression type: lzo
Backoff: 400
Drive: 02 CHANNEL: 00 TARGET: 03 LUN: 00
Library ID: 10 Slot: 03
Vendor identification: IBM
Product identification: ULT3580-TD6
Unit serial number: XYZZY_A3
NAA: 10:22:33:44:ab:cd:ef:03
Compression: factor 3 enabled 1
Compression type: lzo
Backoff: 400
Drive: 03 CHANNEL: 00 TARGET: 04 LUN: 00
Library ID: 10 Slot: 04
Vendor identification: IBM
Product identification: ULT3580-TD6
Unit serial number: XYZZY_A4
NAA: 10:22:33:44:ab:cd:ef:04
Compression: factor 3 enabled 1
Compression type: lzo
Backoff: 400
EOF
fi
if [ ! -f "$MHVTL_CONFIG/library_contents.10" ]; then
/usr/bin/mktape -l 10 -s 100 -m /opt/mhvtl -t LTO5 -d 20
fi
modprobe mhvtl
systemctl daemon-reload
systemctl enable mhvtl
systemctl start mhvtl
echo ""
echo "=========================================="
echo " mhvtl Installation Complete!"
echo "=========================================="
echo ""
echo "Configuration:"
echo " - Config directory: $MHVTL_CONFIG"
echo " - Data directory: $MHVTL_DIR"
echo " - Library: STK L700 (ID: 10)"
echo " - Drives: 4x LTO-5/6 drives"
echo " - Media: 20 LTO-5 tapes"
echo ""
echo "Check status:"
echo " systemctl status mhvtl"
echo " lsscsi -g"
echo " mtx -f /dev/sg0 status"
echo ""

View File

@@ -0,0 +1,47 @@
#!/bin/bash
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
print_info() {
echo -e "${YELLOW}${NC} $1"
}
print_success() {
echo -e "${GREEN}${NC} $1"
}
print_error() {
echo -e "${RED}${NC} $1"
}
if [ "$EUID" -ne 0 ]; then
print_error "Please run as root or with sudo"
exit 1
fi
print_info "Loading mhvtl kernel modules..."
if lsmod | grep -q mhvtl; then
print_info "mhvtl modules already loaded"
else
if [ -f /lib/modules/$(uname -r)/kernel/drivers/scsi/mhvtl.ko ]; then
modprobe mhvtl
print_success "mhvtl kernel module loaded"
else
print_info "Kernel module not found, using userspace mode"
fi
fi
if [ -f /usr/bin/vtllibrary ]; then
print_success "mhvtl is ready"
echo ""
print_info "Start mhvtl daemon with: systemctl start mhvtl"
else
print_error "mhvtl binaries not found"
exit 1
fi

View File

@@ -0,0 +1,114 @@
#!/bin/bash
set -e
echo "=========================================="
echo " VTL Linux Post-Install Setup"
echo "=========================================="
echo ""
if [ "$EUID" -ne 0 ]; then
echo "Error: This script must be run as root"
exit 1
fi
echo "[1/5] Applying system optimizations..."
if [ -f "/tmp/sysctl-vtl.conf" ]; then
cp /tmp/sysctl-vtl.conf /etc/sysctl.d/99-vtl.conf
sysctl -p /etc/sysctl.d/99-vtl.conf
fi
echo "[2/5] Installing mhvtl..."
if [ -f "/usr/local/bin/install-mhvtl.sh" ]; then
bash /usr/local/bin/install-mhvtl.sh
else
echo "Warning: mhvtl installation script not found"
fi
echo "[3/5] Configuring iSCSI targets..."
if [ -f "/usr/local/bin/configure-iscsi.sh" ]; then
bash /usr/local/bin/configure-iscsi.sh
else
echo "Warning: iSCSI configuration script not found"
fi
echo "[4/5] Setting up monitoring..."
cat > /usr/local/bin/vtl-status << 'EOF'
#!/bin/bash
echo "=========================================="
echo " VTL System Status"
echo "=========================================="
echo ""
echo "=== mhvtl Status ==="
systemctl status mhvtl --no-pager | head -n 10
echo ""
echo "=== SCSI Devices ==="
lsscsi -g
echo ""
echo "=== Library Status ==="
if [ -e /dev/sg0 ]; then
mtx -f /dev/sg0 status 2>/dev/null || echo "Library not ready"
fi
echo ""
echo "=== iSCSI Targets ==="
tgt-admin --show
echo ""
echo "=== Network Interfaces ==="
ip -br addr
echo ""
echo "=== Disk Usage ==="
df -h /opt/mhvtl 2>/dev/null || echo "/opt/mhvtl not mounted"
echo ""
EOF
chmod +x /usr/local/bin/vtl-status
echo "[5/5] Creating welcome message..."
cat > /etc/motd << 'EOF'
__ _______ _ _ _
\ \ / /_ _| | | | (_)
\ \ / / | | | | | | _ _ __ _ ___ __
\ \/ / | | | | | | | | '_ \| | | \ \/ /
\ / _| |_| |____ | |___| | | | | |_| |> <
\/ |_____|______||_____|_|_| |_|\__,_/_/\_\
Virtual Tape Library Distribution v1.0
========================================
Quick Commands:
vtl-status - Show VTL system status
systemctl status mhvtl - Check mhvtl service
lsscsi -g - List SCSI devices
tgt-admin --show - Show iSCSI targets
Default Credentials:
User: vtladmin / Password: vtladmin
Root: root / Password: vtlroot
iSCSI Authentication:
Username: vtl-user
Password: vtl-password
========================================
EOF
echo ""
echo "=========================================="
echo " Post-Install Setup Complete!"
echo "=========================================="
echo ""
echo "Next steps:"
echo " 1. Configure network settings"
echo " 2. Change default passwords"
echo " 3. Customize mhvtl configuration in /etc/mhvtl/"
echo " 4. Update iSCSI targets in /etc/tgt/conf.d/"
echo " 5. Run 'vtl-status' to verify setup"
echo ""

View File

@@ -0,0 +1,46 @@
#!/bin/bash
CONFIG_FILE="/etc/mhvtl/device.conf"
if [ ! -f "$CONFIG_FILE" ]; then
echo "Error: Configuration file not found: $CONFIG_FILE"
exit 1
fi
echo "Starting mhvtl Virtual Tape Library..."
rm -f /var/lock/mhvtl/mhvtl* 2>/dev/null || true
modprobe mhvtl 2>/dev/null || echo "Note: Running in userspace mode (kernel module not available)"
sleep 1
DRIVE_NUMS=$(grep "^Drive:" "$CONFIG_FILE" | awk '{print $2}' | sort -u)
for drive in $DRIVE_NUMS; do
if ! pgrep -f "vtltape.*-q $drive" > /dev/null; then
echo "Starting vtltape for drive $drive..."
/usr/bin/vtltape -q $drive 2>&1 | grep -v "Could not locate mhvtl kernel module" || true
else
echo "vtltape for drive $drive is already running"
fi
done
sleep 2
LIBRARY_NUMS=$(grep "^Library:" "$CONFIG_FILE" | awk '{print $2}' | sort -u)
for library in $LIBRARY_NUMS; do
if ! pgrep -f "vtllibrary.*$library" > /dev/null; then
echo "Starting vtllibrary for library $library..."
/usr/bin/vtllibrary $library 2>&1 || echo "Warning: Failed to start vtllibrary for library $library"
else
echo "vtllibrary for library $library is already running"
fi
done
RUNNING_DRIVES=$(pgrep -f "vtltape" | wc -l)
RUNNING_LIBS=$(pgrep -f "vtllibrary" | wc -l)
echo "mhvtl started: $RUNNING_DRIVES drives, $RUNNING_LIBS libraries"
exit 0

View File

@@ -0,0 +1,15 @@
#!/bin/bash
echo "Stopping mhvtl Virtual Tape Library..."
killall vtllibrary 2>/dev/null || true
killall vtltape 2>/dev/null || true
sleep 2
rm -f /var/lock/mhvtl/mhvtl* 2>/dev/null || true
rmmod mhvtl 2>/dev/null || true
echo "mhvtl stopped successfully"
exit 0

View File

@@ -0,0 +1,39 @@
#!/bin/bash
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
print_info() {
echo -e "${YELLOW}${NC} $1"
}
print_success() {
echo -e "${GREEN}${NC} $1"
}
print_error() {
echo -e "${RED}${NC} $1"
}
if [ "$EUID" -ne 0 ]; then
print_error "Please run as root or with sudo"
exit 1
fi
print_info "Stopping mhvtl services..."
systemctl stop mhvtl 2>/dev/null || true
print_info "Unloading mhvtl kernel modules..."
if lsmod | grep -q mhvtl; then
rmmod mhvtl 2>/dev/null || true
print_success "mhvtl kernel module unloaded"
else
print_info "mhvtl modules not loaded"
fi
print_success "mhvtl unloaded"

View File

@@ -0,0 +1,18 @@
[Unit]
Description=mhvtl Virtual Tape Library
After=network.target
Documentation=man:vtltape(1) man:vtllibrary(1)
[Service]
Type=forking
ExecStart=/opt/adastra-vtl/scripts/start-mhvtl.sh
ExecStop=/opt/adastra-vtl/scripts/stop-mhvtl.sh
RemainAfterExit=yes
Restart=on-failure
RestartSec=10s
User=root
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

88
dist/adastra-vtl-installer/uninstall.sh vendored Executable file
View File

@@ -0,0 +1,88 @@
#!/bin/bash
set -e
INSTALL_DIR="/opt/adastra-vtl"
WEB_DIR="/var/www/html/mhvtl-config"
SYSTEMD_DIR="/etc/systemd/system"
CONFIG_DIR="/etc/mhvtl"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
print_info() {
echo -e "${YELLOW}${NC} $1"
}
print_success() {
echo -e "${GREEN}${NC} $1"
}
print_error() {
echo -e "${RED}${NC} $1"
}
check_root() {
if [ "$EUID" -ne 0 ]; then
print_error "Please run as root or with sudo"
exit 1
fi
}
uninstall_adastra() {
print_info "Stopping services..."
systemctl stop mhvtl 2>/dev/null || true
systemctl disable mhvtl 2>/dev/null || true
print_info "Unloading kernel modules..."
if [ -f /usr/local/bin/mhvtl-unload ]; then
/usr/local/bin/mhvtl-unload 2>/dev/null || true
fi
print_info "Removing files..."
rm -rf "$INSTALL_DIR"
rm -rf "$WEB_DIR"
rm -f "$SYSTEMD_DIR"/mhvtl*.service
rm -f /usr/local/bin/mhvtl-load
rm -f /usr/local/bin/mhvtl-unload
systemctl daemon-reload
print_info "Removing mhvtl..."
rm -f /usr/bin/vtl*
rm -f /usr/bin/mktape
rm -f /lib/modules/$(uname -r)/kernel/drivers/scsi/mhvtl.ko 2>/dev/null || true
depmod -a
print_success "Adastra VTL uninstalled"
echo ""
print_info "Note: Configuration files in $CONFIG_DIR were preserved"
print_info "Note: User 'vtl' and group 'vtl' were preserved"
print_info "To remove them manually:"
echo " userdel vtl"
echo " groupdel vtl"
echo " rm -rf $CONFIG_DIR"
echo " rm -rf /var/lib/mhvtl"
}
main() {
echo "Adastra VTL Uninstaller"
echo ""
check_root
read -p "Are you sure you want to uninstall Adastra VTL? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
uninstall_adastra
else
print_info "Uninstall cancelled"
exit 0
fi
}
main "$@"

View File

@@ -0,0 +1,65 @@
# mhvtl Configuration Web UI
Web-based configuration manager for mhvtl (Virtual Tape Library).
## Features
- 📚 Library configuration
- 💾 Drive management (add/remove/configure)
- 📼 Tape generation settings
- 📤 Export configuration files
- 🎨 Modern UI with Adastra theme
## Usage
### Local Development
Simply open `index.html` in your web browser:
```bash
cd web-ui
python3 -m http.server 8080
# or
php -S localhost:8080
```
Then open http://localhost:8080 in your browser.
### Deploy to VTL System
Copy the web-ui directory to your VTL system:
```bash
scp -r web-ui/ root@vtl-server:/var/www/html/mhvtl-config/
```
Or include it in the ISO build by adding to the build script.
## Configuration Workflow
1. **Library Tab**: Configure library settings (ID, vendor, serial, etc.)
2. **Drives Tab**: Add/remove drives and configure each drive
3. **Tapes Tab**: Set tape generation parameters
4. **Export Tab**:
- Generate configuration preview
- Download `device.conf` file
- Copy mktape command for tape generation
## Generated Files
- `device.conf` - Main mhvtl configuration file (goes to `/etc/mhvtl/`)
- `mktape` command - Run this to generate virtual tapes
## Integration with Build
To include this in the ISO build, add to `build/build-iso.sh`:
```bash
# Copy web UI
mkdir -p "$WORK_DIR/chroot/var/www/html"
cp -r web-ui "$WORK_DIR/chroot/var/www/html/mhvtl-config"
```
## Customization
Edit `style.css` to customize colors and theme. Current theme matches adastra.id branding.

View File

@@ -0,0 +1,655 @@
<?php
header('Content-Type: application/json');
// Configuration
$CONFIG_DIR = '/etc/mhvtl';
$DEVICE_CONF = $CONFIG_DIR . '/device.conf';
$BACKUP_DIR = $CONFIG_DIR . '/backups';
// Ensure backup directory exists
if (!is_dir($BACKUP_DIR)) {
mkdir($BACKUP_DIR, 0755, true);
}
// Get POST data
$input = json_decode(file_get_contents('php://input'), true);
if (!$input || !isset($input['action'])) {
echo json_encode(['success' => false, 'error' => 'Invalid request']);
exit;
}
$action = $input['action'];
switch ($action) {
case 'save_config':
saveConfig($input['config']);
break;
case 'load_config':
loadConfig();
break;
case 'restart_service':
restartService();
break;
case 'list_tapes':
listTapes();
break;
case 'delete_tape':
deleteTape($input['tape_name']);
break;
case 'bulk_delete_tapes':
bulkDeleteTapes($input['pattern']);
break;
case 'create_tapes':
createTapes($input);
break;
case 'list_targets':
listTargets();
break;
case 'create_target':
createTarget($input);
break;
case 'delete_target':
deleteTarget($input['tid']);
break;
case 'add_lun':
addLun($input);
break;
case 'bind_initiator':
bindInitiator($input);
break;
case 'unbind_initiator':
unbindInitiator($input);
break;
default:
echo json_encode(['success' => false, 'error' => 'Unknown action']);
}
// ============================================
// iSCSI Target Management Functions
// ============================================
function listTargets() {
$output = [];
$returnCode = 0;
exec('sudo tgtadm --lld iscsi --mode target --op show 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
echo json_encode([
'success' => false,
'error' => 'Failed to list targets: ' . implode(' ', $output)
]);
return;
}
$targets = [];
$currentTarget = null;
$inACLSection = false;
foreach ($output as $line) {
if (preg_match('/^Target (\d+): (.+)$/', $line, $matches)) {
if ($currentTarget) {
$targets[] = $currentTarget;
}
$currentTarget = [
'tid' => intval($matches[1]),
'name' => trim($matches[2]),
'luns' => 0,
'acls' => 0
];
$inACLSection = false;
} elseif ($currentTarget && preg_match('/^\s+LUN: (\d+)/', $line)) {
$currentTarget['luns']++;
$inACLSection = false;
} elseif ($currentTarget && preg_match('/^\s+ACL information:/', $line)) {
$inACLSection = true;
} elseif ($currentTarget && $inACLSection && preg_match('/^\s+(.+)$/', $line, $matches)) {
$acl = trim($matches[1]);
if (!empty($acl) && !preg_match('/^(Account|I_T nexus|LUN|System)/', $acl)) {
$currentTarget['acls']++;
}
}
}
if ($currentTarget) {
$targets[] = $currentTarget;
}
echo json_encode([
'success' => true,
'targets' => $targets
]);
}
function createTarget($params) {
$tid = isset($params['tid']) ? intval($params['tid']) : 0;
$name = isset($params['name']) ? trim($params['name']) : '';
if ($tid <= 0) {
echo json_encode(['success' => false, 'error' => 'Invalid TID']);
return;
}
if (empty($name)) {
echo json_encode(['success' => false, 'error' => 'Target name is required']);
return;
}
if (!preg_match('/^[a-zA-Z0-9._-]+$/', $name)) {
echo json_encode(['success' => false, 'error' => 'Invalid target name format']);
return;
}
$iqn = "iqn.2024-01.com.vtl-linux:$name";
$command = sprintf(
'sudo tgtadm --lld iscsi --mode target --op new --tid %d --targetname %s 2>&1',
$tid,
escapeshellarg($iqn)
);
$output = [];
$returnCode = 0;
exec($command, $output, $returnCode);
if ($returnCode === 0) {
echo json_encode([
'success' => true,
'message' => 'Target created successfully',
'iqn' => $iqn
]);
} else {
echo json_encode([
'success' => false,
'error' => 'Failed to create target: ' . implode(' ', $output)
]);
}
}
function deleteTarget($tid) {
$tid = intval($tid);
if ($tid <= 0) {
echo json_encode(['success' => false, 'error' => 'Invalid TID']);
return;
}
$command = sprintf(
'sudo tgtadm --lld iscsi --mode target --op delete --force --tid %d 2>&1',
$tid
);
$output = [];
$returnCode = 0;
exec($command, $output, $returnCode);
if ($returnCode === 0) {
echo json_encode([
'success' => true,
'message' => 'Target deleted successfully'
]);
} else {
echo json_encode([
'success' => false,
'error' => 'Failed to delete target: ' . implode(' ', $output)
]);
}
}
function addLun($params) {
$tid = isset($params['tid']) ? intval($params['tid']) : 0;
$lun = isset($params['lun']) ? intval($params['lun']) : 0;
$device = isset($params['device']) ? trim($params['device']) : '';
if ($tid <= 0) {
echo json_encode(['success' => false, 'error' => 'Invalid TID']);
return;
}
if ($lun < 0) {
echo json_encode(['success' => false, 'error' => 'Invalid LUN number']);
return;
}
if (empty($device)) {
echo json_encode(['success' => false, 'error' => 'Device path is required']);
return;
}
if (!preg_match('#^/dev/(sg\d+|sd[a-z]+)$#', $device)) {
echo json_encode(['success' => false, 'error' => 'Invalid device path']);
return;
}
if (!file_exists($device)) {
echo json_encode(['success' => false, 'error' => 'Device does not exist']);
return;
}
$command = sprintf(
'sudo tgtadm --lld iscsi --mode logicalunit --op new --tid %d --lun %d --backing-store %s 2>&1',
$tid,
$lun,
escapeshellarg($device)
);
$output = [];
$returnCode = 0;
exec($command, $output, $returnCode);
if ($returnCode === 0) {
echo json_encode([
'success' => true,
'message' => 'LUN added successfully'
]);
} else {
echo json_encode([
'success' => false,
'error' => 'Failed to add LUN: ' . implode(' ', $output)
]);
}
}
function bindInitiator($params) {
$tid = isset($params['tid']) ? intval($params['tid']) : 0;
$address = isset($params['address']) ? trim($params['address']) : '';
if ($tid <= 0) {
echo json_encode(['success' => false, 'error' => 'Invalid TID']);
return;
}
if (empty($address)) {
echo json_encode(['success' => false, 'error' => 'Initiator address is required']);
return;
}
if ($address !== 'ALL' && !filter_var($address, FILTER_VALIDATE_IP)) {
echo json_encode(['success' => false, 'error' => 'Invalid IP address']);
return;
}
$command = sprintf(
'sudo tgtadm --lld iscsi --mode target --op bind --tid %d --initiator-address %s 2>&1',
$tid,
escapeshellarg($address)
);
$output = [];
$returnCode = 0;
exec($command, $output, $returnCode);
if ($returnCode === 0) {
echo json_encode([
'success' => true,
'message' => 'Initiator allowed successfully'
]);
} else {
echo json_encode([
'success' => false,
'error' => 'Failed to bind initiator: ' . implode(' ', $output)
]);
}
}
function unbindInitiator($params) {
$tid = isset($params['tid']) ? intval($params['tid']) : 0;
$address = isset($params['address']) ? trim($params['address']) : '';
if ($tid <= 0) {
echo json_encode(['success' => false, 'error' => 'Invalid TID']);
return;
}
if (empty($address)) {
echo json_encode(['success' => false, 'error' => 'Initiator address is required']);
return;
}
if ($address !== 'ALL' && !filter_var($address, FILTER_VALIDATE_IP)) {
echo json_encode(['success' => false, 'error' => 'Invalid IP address']);
return;
}
$command = sprintf(
'sudo tgtadm --lld iscsi --mode target --op unbind --tid %d --initiator-address %s 2>&1',
$tid,
escapeshellarg($address)
);
$output = [];
$returnCode = 0;
exec($command, $output, $returnCode);
if ($returnCode === 0) {
echo json_encode([
'success' => true,
'message' => 'Initiator blocked successfully'
]);
} else {
echo json_encode([
'success' => false,
'error' => 'Failed to unbind initiator: ' . implode(' ', $output)
]);
}
}
function createTapes($params) {
$library = isset($params['library']) ? intval($params['library']) : 10;
$barcodePrefix = isset($params['barcode_prefix']) ? trim($params['barcode_prefix']) : '';
$startNum = isset($params['start_num']) ? intval($params['start_num']) : 0;
$count = isset($params['count']) ? intval($params['count']) : 1;
$size = isset($params['size']) ? intval($params['size']) : 2500000;
$mediaType = isset($params['media_type']) ? $params['media_type'] : 'data';
$density = isset($params['density']) ? $params['density'] : 'LTO6';
if (empty($barcodePrefix)) {
echo json_encode(['success' => false, 'error' => 'Barcode prefix is required']);
return;
}
if ($count < 1 || $count > 100) {
echo json_encode(['success' => false, 'error' => 'Count must be between 1 and 100']);
return;
}
if (strlen($barcodePrefix) > 6) {
echo json_encode(['success' => false, 'error' => 'Barcode prefix too long (max 6 chars)']);
return;
}
$validMediaTypes = ['data', 'clean', 'WORM'];
if (!in_array($mediaType, $validMediaTypes)) {
echo json_encode(['success' => false, 'error' => 'Invalid media type']);
return;
}
$validDensities = ['LTO5', 'LTO6', 'LTO7', 'LTO8', 'LTO9'];
if (!in_array($density, $validDensities)) {
echo json_encode(['success' => false, 'error' => 'Invalid density']);
return;
}
$createdCount = 0;
$errors = [];
for ($i = 0; $i < $count; $i++) {
$barcodeNum = str_pad($startNum + $i, 6, '0', STR_PAD_LEFT);
$barcode = $barcodePrefix . $barcodeNum;
$command = sprintf(
'mktape -l %d -m %s -s %d -t %s -d %s 2>&1',
$library,
escapeshellarg($barcode),
$size,
escapeshellarg($mediaType),
escapeshellarg($density)
);
$output = [];
$returnCode = 0;
exec($command, $output, $returnCode);
if ($returnCode === 0) {
$createdCount++;
} else {
$errors[] = $barcode . ': ' . implode(' ', $output);
}
}
if ($createdCount > 0) {
$response = [
'success' => true,
'created_count' => $createdCount,
'message' => "Created $createdCount tape(s)"
];
if (!empty($errors)) {
$response['errors'] = $errors;
}
echo json_encode($response);
} else {
echo json_encode([
'success' => false,
'error' => 'Failed to create tapes: ' . implode('; ', $errors)
]);
}
}
function saveConfig($config) {
global $DEVICE_CONF, $BACKUP_DIR;
if (empty($config)) {
echo json_encode(['success' => false, 'error' => 'Empty configuration']);
return;
}
// Create backup of existing config
if (file_exists($DEVICE_CONF)) {
$backupFile = $BACKUP_DIR . '/device.conf.' . date('Y-m-d_H-i-s');
if (!copy($DEVICE_CONF, $backupFile)) {
echo json_encode(['success' => false, 'error' => 'Failed to create backup']);
return;
}
}
// Write new config
if (file_put_contents($DEVICE_CONF, $config) === false) {
echo json_encode(['success' => false, 'error' => 'Failed to write configuration file. Check permissions.']);
return;
}
// Set proper permissions
chmod($DEVICE_CONF, 0644);
echo json_encode([
'success' => true,
'file' => $DEVICE_CONF,
'backup' => isset($backupFile) ? $backupFile : null,
'message' => 'Configuration saved successfully'
]);
}
function loadConfig() {
global $DEVICE_CONF;
if (!file_exists($DEVICE_CONF)) {
echo json_encode(['success' => false, 'error' => 'Configuration file not found']);
return;
}
$config = file_get_contents($DEVICE_CONF);
echo json_encode([
'success' => true,
'config' => $config
]);
}
function restartService() {
// Check if user has sudo privileges
$output = [];
$returnCode = 0;
exec('sudo systemctl restart mhvtl 2>&1', $output, $returnCode);
if ($returnCode === 0) {
echo json_encode([
'success' => true,
'message' => 'Service restarted successfully'
]);
} else {
echo json_encode([
'success' => false,
'error' => 'Failed to restart service: ' . implode("\n", $output)
]);
}
}
function listTapes() {
$tapeDir = '/opt/mhvtl';
if (!is_dir($tapeDir)) {
echo json_encode(['success' => false, 'error' => 'Tape directory not found']);
return;
}
$tapes = [];
$items = scandir($tapeDir);
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $tapeDir . '/' . $item;
if (is_dir($path)) {
$stat = stat($path);
$size = getDirSize($path);
$tapes[] = [
'name' => $item,
'size' => formatBytes($size),
'modified' => date('Y-m-d H:i:s', $stat['mtime'])
];
}
}
usort($tapes, function($a, $b) {
return strcmp($a['name'], $b['name']);
});
echo json_encode([
'success' => true,
'tapes' => $tapes
]);
}
function getDirSize($dir) {
$size = 0;
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)) as $file) {
$size += $file->getSize();
}
return $size;
}
function formatBytes($bytes) {
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= pow(1024, $pow);
return round($bytes, 2) . ' ' . $units[$pow];
}
function deleteTape($tapeName) {
$tapeDir = '/opt/mhvtl';
$tapePath = $tapeDir . '/' . basename($tapeName);
if (!file_exists($tapePath)) {
echo json_encode(['success' => false, 'error' => 'Tape not found']);
return;
}
if (!is_dir($tapePath)) {
echo json_encode(['success' => false, 'error' => 'Invalid tape path']);
return;
}
if (strpos(realpath($tapePath), realpath($tapeDir)) !== 0) {
echo json_encode(['success' => false, 'error' => 'Security violation: Path traversal detected']);
return;
}
$output = [];
$returnCode = 0;
exec('sudo rm -rf ' . escapeshellarg($tapePath) . ' 2>&1', $output, $returnCode);
if ($returnCode === 0) {
echo json_encode([
'success' => true,
'message' => 'Tape deleted successfully'
]);
} else {
echo json_encode([
'success' => false,
'error' => 'Failed to delete tape: ' . implode("\n", $output)
]);
}
}
function bulkDeleteTapes($pattern) {
$tapeDir = '/opt/mhvtl';
if (empty($pattern)) {
echo json_encode(['success' => false, 'error' => 'Pattern is required']);
return;
}
if (strpos($pattern, '/') !== false || strpos($pattern, '..') !== false) {
echo json_encode(['success' => false, 'error' => 'Invalid pattern']);
return;
}
$items = glob($tapeDir . '/' . $pattern, GLOB_ONLYDIR);
if (empty($items)) {
echo json_encode([
'success' => true,
'deleted_count' => 0,
'message' => 'No tapes found matching pattern'
]);
return;
}
$deletedCount = 0;
$errors = [];
foreach ($items as $item) {
if (strpos(realpath($item), realpath($tapeDir)) !== 0) {
continue;
}
$output = [];
$returnCode = 0;
exec('sudo rm -rf ' . escapeshellarg($item) . ' 2>&1', $output, $returnCode);
if ($returnCode === 0) {
$deletedCount++;
} else {
$errors[] = basename($item) . ': ' . implode(' ', $output);
}
}
if ($deletedCount > 0) {
$message = "Deleted $deletedCount tape(s)";
if (!empty($errors)) {
$message .= '. Errors: ' . implode('; ', $errors);
}
echo json_encode([
'success' => true,
'deleted_count' => $deletedCount,
'message' => $message
]);
} else {
echo json_encode([
'success' => false,
'error' => 'Failed to delete tapes: ' . implode('; ', $errors)
]);
}
}
?>

View File

@@ -0,0 +1,458 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>mhvtl Configuration Manager - Adastra VTL</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<nav class="navbar">
<div class="container">
<div class="nav-brand">
<h1>🎞️ Adastra VTL</h1>
<span class="subtitle">Virtual Tape Library Configuration</span>
</div>
<div class="nav-links">
<a href="#library" class="nav-link active">Library</a>
<a href="#drives" class="nav-link">Drives</a>
<a href="#tapes" class="nav-link">Tapes</a>
<a href="#manage-tapes" class="nav-link">Manage Tapes</a>
<a href="#iscsi" class="nav-link">iSCSI</a>
<a href="#export" class="nav-link">Export</a>
</div>
</div>
</nav>
<main class="container">
<section id="library" class="section active">
<div class="section-header">
<h2>📚 Library Configuration</h2>
<p>Configure your virtual tape library settings</p>
</div>
<div class="card">
<div class="card-header">
<h3>Library Settings</h3>
</div>
<div class="card-body">
<div class="form-grid">
<div class="form-group">
<label for="lib-id">Library ID</label>
<input type="number" id="lib-id" value="10" min="0" max="99">
</div>
<div class="form-group">
<label for="lib-channel">Channel</label>
<input type="number" id="lib-channel" value="0" min="0" max="15">
</div>
<div class="form-group">
<label for="lib-target">Target</label>
<input type="number" id="lib-target" value="0" min="0" max="15">
</div>
<div class="form-group">
<label for="lib-lun">LUN</label>
<input type="number" id="lib-lun" value="0" min="0" max="7">
</div>
<div class="form-group">
<label for="lib-vendor">Vendor</label>
<input type="text" id="lib-vendor" value="STK" maxlength="8">
</div>
<div class="form-group">
<label for="lib-product">Product</label>
<input type="text" id="lib-product" value="L700" maxlength="16">
</div>
<div class="form-group">
<label for="lib-serial">Serial Number</label>
<input type="text" id="lib-serial" value="XYZZY_A" maxlength="10">
</div>
<div class="form-group">
<label for="lib-naa">NAA</label>
<input type="text" id="lib-naa" value="10:22:33:44:ab:cd:ef:00" pattern="[0-9a-f:]+">
</div>
<div class="form-group">
<label for="lib-home">Home Directory</label>
<input type="text" id="lib-home" value="/opt/mhvtl">
</div>
<div class="form-group">
<label for="lib-backoff">Backoff (ms)</label>
<input type="number" id="lib-backoff" value="400" min="0" max="10000">
</div>
</div>
</div>
</div>
</section>
<section id="drives" class="section">
<div class="section-header">
<h2>💾 Drive Configuration</h2>
<p>Manage virtual tape drives</p>
</div>
<div class="drives-container" id="drives-container">
</div>
<button class="btn btn-primary" onclick="addDrive()">
<span></span> Add Drive
</button>
</section>
<section id="tapes" class="section">
<div class="section-header">
<h2>📼 Tape Configuration</h2>
<p>Configure virtual tape media</p>
</div>
<div class="card">
<div class="card-header">
<h3>Tape Generation Settings</h3>
</div>
<div class="card-body">
<div class="form-grid">
<div class="form-group">
<label for="tape-library">Library ID</label>
<input type="number" id="tape-library" value="10" min="0" max="99">
</div>
<div class="form-group">
<label for="tape-barcode-prefix">Barcode Prefix</label>
<input type="text" id="tape-barcode-prefix" value="CLN" maxlength="6">
</div>
<div class="form-group">
<label for="tape-start-num">Starting Number</label>
<input type="number" id="tape-start-num" value="100" min="1" max="9999">
</div>
<div class="form-group">
<label for="tape-size">Tape Size (MB)</label>
<input type="number" id="tape-size" value="2500000" min="1000" max="10000000">
</div>
<div class="form-group">
<label for="tape-media-type">Media Type</label>
<select id="tape-media-type">
<option value="data">Data</option>
<option value="clean">Cleaning</option>
<option value="WORM">WORM</option>
</select>
</div>
<div class="form-group">
<label for="tape-density">Density</label>
<select id="tape-density">
<option value="LTO5">LTO-5</option>
<option value="LTO6" selected>LTO-6</option>
<option value="LTO7">LTO-7</option>
<option value="LTO8">LTO-8</option>
<option value="LTO9">LTO-9</option>
</select>
</div>
<div class="form-group">
<label for="tape-count">Number of Tapes</label>
<input type="number" id="tape-count" value="20" min="1" max="1000">
</div>
</div>
<div class="alert alert-info">
<strong> Info:</strong> Generate mktape commands for creating virtual tapes. Run these commands on the server after installation.
</div>
</div>
</div>
</section>
<section id="manage-tapes" class="section">
<div class="section-header">
<h2>🗂️ Manage Virtual Tapes</h2>
<p>Complete CRUD management for virtual tape files</p>
</div>
<div class="card">
<div class="card-header">
<h3> Create New Tapes</h3>
</div>
<div class="card-body">
<div class="form-grid">
<div class="form-group">
<label for="create-library">Library Number</label>
<input type="number" id="create-library" value="10" min="1">
</div>
<div class="form-group">
<label for="create-barcode-prefix">Barcode Prefix</label>
<input type="text" id="create-barcode-prefix" value="CLN" maxlength="6">
</div>
<div class="form-group">
<label for="create-start-num">Starting Number</label>
<input type="number" id="create-start-num" value="100" min="0">
</div>
<div class="form-group">
<label for="create-count">Number of Tapes</label>
<input type="number" id="create-count" value="1" min="1" max="100">
</div>
<div class="form-group">
<label for="create-size">Tape Size (MB)</label>
<input type="number" id="create-size" value="2500000" min="1000">
<small>Default: 2.5TB = 2,500,000 MB</small>
</div>
<div class="form-group">
<label for="create-media-type">Media Type</label>
<select id="create-media-type">
<option value="data">Data</option>
<option value="clean">Cleaning</option>
<option value="WORM">WORM</option>
</select>
</div>
<div class="form-group">
<label for="create-density">Density</label>
<select id="create-density">
<option value="LTO5">LTO-5</option>
<option value="LTO6" selected>LTO-6</option>
<option value="LTO7">LTO-7</option>
<option value="LTO8">LTO-8</option>
<option value="LTO9">LTO-9</option>
</select>
</div>
</div>
<div style="margin-top: 1rem;">
<button class="btn btn-success" onclick="createTapes()">
<span></span> Create Tapes
</button>
<div id="create-result" class="alert" style="display: none; margin-top: 1rem;"></div>
</div>
</div>
</div>
<div class="card" style="margin-top: 1rem;">
<div class="card-header">
<h3>📋 Tape Files</h3>
<button class="btn btn-primary" onclick="loadTapeList()">
<span>🔄</span> Refresh List
</button>
</div>
<div class="card-body">
<div id="tape-list-loading" style="display: none; text-align: center; padding: 2rem;">
<strong></strong> Loading tape files...
</div>
<div id="tape-list-error" class="alert alert-danger" style="display: none;"></div>
<div id="tape-list-empty" class="alert alert-info" style="display: none;">
<strong></strong> No tape files found. Create tapes using the commands from the "Tapes" section.
</div>
<div id="tape-list-container" style="display: none;">
<div style="margin-bottom: 1rem;">
<input type="text" id="tape-search" placeholder="🔍 Search tapes..."
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;"
onkeyup="filterTapes()">
</div>
<table class="tape-table" id="tape-table">
<thead>
<tr>
<th>Barcode</th>
<th>Size</th>
<th>Modified</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="tape-list-body">
</tbody>
</table>
<div style="margin-top: 1rem; padding: 1rem; background: #f8f9fa; border-radius: 4px;">
<strong>Total Tapes:</strong> <span id="tape-count-display">0</span>
</div>
</div>
</div>
</div>
<div class="card" style="margin-top: 1rem;">
<div class="card-header">
<h3>Bulk Actions</h3>
</div>
<div class="card-body">
<p>Delete multiple tapes at once. Use with caution!</p>
<div class="form-group">
<label for="bulk-delete-pattern">Delete Pattern (e.g., CLN*)</label>
<input type="text" id="bulk-delete-pattern" placeholder="CLN*">
</div>
<button class="btn btn-danger" onclick="bulkDeleteTapes()">
<span>🗑️</span> Bulk Delete
</button>
<div id="bulk-delete-result" class="alert" style="display: none; margin-top: 1rem;"></div>
</div>
</div>
</section>
<section id="iscsi" class="section">
<div class="section-header">
<h2>🔌 iSCSI Target Management</h2>
<p>Manage iSCSI targets, initiators, and LUNs</p>
</div>
<div class="card">
<div class="card-header">
<h3>🎯 iSCSI Targets</h3>
<button class="btn btn-primary" onclick="loadTargets()">
<span>🔄</span> Refresh
</button>
</div>
<div class="card-body">
<div id="target-list-empty" class="alert alert-info">
<strong></strong> No targets configured. Create a target below.
</div>
<div id="target-list-container" style="display: none;">
<table class="tape-table" id="target-table">
<thead>
<tr>
<th>TID</th>
<th>Target Name (IQN)</th>
<th>LUNs</th>
<th>ACLs</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="target-list-body">
</tbody>
</table>
</div>
</div>
</div>
<div class="card" style="margin-top: 1rem;">
<div class="card-header">
<h3> Create New Target</h3>
</div>
<div class="card-body">
<div class="form-grid">
<div class="form-group">
<label for="target-tid">Target ID (TID)</label>
<input type="number" id="target-tid" value="1" min="1">
<small>Unique target identifier</small>
</div>
<div class="form-group">
<label for="target-name">Target Name</label>
<input type="text" id="target-name" placeholder="vtl.drive0">
<small>Will be: iqn.2024-01.com.vtl-linux:<name></small>
</div>
</div>
<button class="btn btn-success" onclick="createTarget()">
<span></span> Create Target
</button>
<div id="create-target-result" class="alert" style="display: none; margin-top: 1rem;"></div>
</div>
</div>
<div class="card" style="margin-top: 1rem;">
<div class="card-header">
<h3>💾 Add LUN (Backing Store)</h3>
</div>
<div class="card-body">
<div class="form-grid">
<div class="form-group">
<label for="lun-tid">Target ID</label>
<input type="number" id="lun-tid" value="1" min="1">
</div>
<div class="form-group">
<label for="lun-number">LUN Number</label>
<input type="number" id="lun-number" value="1" min="0">
</div>
<div class="form-group">
<label for="lun-device">Device Path</label>
<input type="text" id="lun-device" placeholder="/dev/sg1">
<small>SCSI generic device (e.g., /dev/sg1, /dev/sg2)</small>
</div>
</div>
<button class="btn btn-success" onclick="addLun()">
<span></span> Add LUN
</button>
<div id="add-lun-result" class="alert" style="display: none; margin-top: 1rem;"></div>
</div>
</div>
<div class="card" style="margin-top: 1rem;">
<div class="card-header">
<h3>🔐 Manage Initiator ACLs</h3>
</div>
<div class="card-body">
<div class="form-grid">
<div class="form-group">
<label for="acl-tid">Target ID</label>
<input type="number" id="acl-tid" value="1" min="1">
</div>
<div class="form-group">
<label for="acl-address">Initiator Address</label>
<input type="text" id="acl-address" placeholder="192.168.1.100 or ALL">
<small>IP address or "ALL" for any initiator</small>
</div>
</div>
<div style="margin-top: 1rem;">
<button class="btn btn-success" onclick="bindInitiator()">
<span></span> Allow Initiator
</button>
<button class="btn btn-danger" onclick="unbindInitiator()">
<span>🚫</span> Block Initiator
</button>
</div>
<div id="acl-result" class="alert" style="display: none; margin-top: 1rem;"></div>
</div>
</div>
</section>
<section id="export" class="section">
<div class="section-header">
<h2>📤 Export Configuration</h2>
<p>Generate and download configuration files</p>
</div>
<div class="card">
<div class="card-header">
<h3>Configuration Preview</h3>
</div>
<div class="card-body">
<pre id="config-preview" class="config-preview"></pre>
</div>
</div>
<div class="button-group">
<button class="btn btn-primary" onclick="generateConfig()">
<span>🔄</span> Generate Config
</button>
<button class="btn btn-success" onclick="applyConfig()">
<span>💾</span> Apply to Server
</button>
<button class="btn btn-success" onclick="downloadConfig()">
<span>⬇️</span> Download device.conf
</button>
<button class="btn btn-secondary" onclick="copyConfig()">
<span>📋</span> Copy to Clipboard
</button>
</div>
<div id="apply-result" class="alert" style="display: none; margin-top: 1rem;"></div>
<div class="card" style="margin-top: 1rem;">
<div class="card-header">
<h3>Service Management</h3>
</div>
<div class="card-body">
<p>After applying configuration, restart the mhvtl service to apply changes.</p>
<button class="btn btn-warning" onclick="restartService()">
<span>🔄</span> Restart mhvtl Service
</button>
<div id="restart-result" class="alert" style="display: none; margin-top: 1rem;"></div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>Installation Command</h3>
</div>
<div class="card-body">
<pre id="install-command" class="config-preview"></pre>
<button class="btn btn-secondary" onclick="copyInstallCommand()">
<span>📋</span> Copy Command
</button>
</div>
</div>
</section>
</main>
<footer class="footer">
<div class="container">
<p>© Adastra Visi Teknologi • <a href="http://adastra.id">adastra.id</a> • mhvtl Configuration Manager</p>
</div>
</footer>
<script src="script.js"></script>
</body>
</html>

View File

@@ -0,0 +1,914 @@
let drives = [];
let driveCounter = 0;
const driveTypes = {
'IBM ULT3580-TD5': { vendor: 'IBM', product: 'ULT3580-TD5', type: 'LTO-5' },
'IBM ULT3580-TD6': { vendor: 'IBM', product: 'ULT3580-TD6', type: 'LTO-6' },
'IBM ULT3580-TD7': { vendor: 'IBM', product: 'ULT3580-TD7', type: 'LTO-7' },
'IBM ULT3580-TD8': { vendor: 'IBM', product: 'ULT3580-TD8', type: 'LTO-8' },
'IBM ULT3580-TD9': { vendor: 'IBM', product: 'ULT3580-TD9', type: 'LTO-9' },
'HP Ultrium 5-SCSI': { vendor: 'HP', product: 'Ultrium 5-SCSI', type: 'LTO-5' },
'HP Ultrium 6-SCSI': { vendor: 'HP', product: 'Ultrium 6-SCSI', type: 'LTO-6' },
};
document.addEventListener('DOMContentLoaded', function() {
initNavigation();
addDefaultDrives();
generateConfig();
});
function initNavigation() {
const navLinks = document.querySelectorAll('.nav-link');
navLinks.forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
const targetId = this.getAttribute('href').substring(1);
document.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active'));
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
this.classList.add('active');
document.getElementById(targetId).classList.add('active');
});
});
}
function addDefaultDrives() {
for (let i = 0; i < 4; i++) {
addDrive(i < 2 ? 'IBM ULT3580-TD5' : 'IBM ULT3580-TD6');
}
}
function addDrive(driveType = 'IBM ULT3580-TD5') {
const driveId = driveCounter++;
const drive = {
id: driveId,
driveNum: drives.length,
channel: 0,
target: drives.length + 1,
lun: 0,
libraryId: 10,
slot: drives.length + 1,
type: driveType,
serial: `XYZZY_A${drives.length + 1}`,
naa: `10:22:33:44:ab:cd:ef:0${drives.length + 1}`,
compression: 3,
compressionEnabled: 1,
compressionType: 'lzo',
backoff: 400
};
drives.push(drive);
renderDrive(drive);
}
function renderDrive(drive) {
const container = document.getElementById('drives-container');
const driveInfo = driveTypes[drive.type];
const driveCard = document.createElement('div');
driveCard.className = 'drive-card';
driveCard.id = `drive-${drive.id}`;
driveCard.innerHTML = `
<div class="drive-card-header">
<h4>💾 Drive ${drive.driveNum}</h4>
<button class="btn btn-danger" onclick="removeDrive(${drive.id})">
<span>🗑️</span> Remove
</button>
</div>
<div class="form-grid">
<div class="form-group">
<label>Drive Type</label>
<select onchange="updateDriveType(${drive.id}, this.value)">
${Object.keys(driveTypes).map(type =>
`<option value="${type}" ${type === drive.type ? 'selected' : ''}>${type}</option>`
).join('')}
</select>
</div>
<div class="form-group">
<label>Drive Number</label>
<input type="number" value="${drive.driveNum}" min="0" max="99"
onchange="updateDrive(${drive.id}, 'driveNum', parseInt(this.value))">
</div>
<div class="form-group">
<label>Channel</label>
<input type="number" value="${drive.channel}" min="0" max="15"
onchange="updateDrive(${drive.id}, 'channel', parseInt(this.value))">
</div>
<div class="form-group">
<label>Target</label>
<input type="number" value="${drive.target}" min="0" max="15"
onchange="updateDrive(${drive.id}, 'target', parseInt(this.value))">
</div>
<div class="form-group">
<label>LUN</label>
<input type="number" value="${drive.lun}" min="0" max="7"
onchange="updateDrive(${drive.id}, 'lun', parseInt(this.value))">
</div>
<div class="form-group">
<label>Library ID</label>
<input type="number" value="${drive.libraryId}" min="0" max="99"
onchange="updateDrive(${drive.id}, 'libraryId', parseInt(this.value))">
</div>
<div class="form-group">
<label>Slot</label>
<input type="number" value="${drive.slot}" min="1" max="999"
onchange="updateDrive(${drive.id}, 'slot', parseInt(this.value))">
</div>
<div class="form-group">
<label>Serial Number</label>
<input type="text" value="${drive.serial}" maxlength="10"
onchange="updateDrive(${drive.id}, 'serial', this.value)">
</div>
<div class="form-group">
<label>NAA</label>
<input type="text" value="${drive.naa}" pattern="[0-9a-f:]+"
onchange="updateDrive(${drive.id}, 'naa', this.value)">
</div>
<div class="form-group">
<label>Compression Factor</label>
<input type="number" value="${drive.compression}" min="1" max="10"
onchange="updateDrive(${drive.id}, 'compression', parseInt(this.value))">
</div>
<div class="form-group">
<label>Compression Type</label>
<select onchange="updateDrive(${drive.id}, 'compressionType', this.value)">
<option value="lzo" ${drive.compressionType === 'lzo' ? 'selected' : ''}>LZO</option>
<option value="zlib" ${drive.compressionType === 'zlib' ? 'selected' : ''}>ZLIB</option>
</select>
</div>
<div class="form-group">
<label>Backoff (ms)</label>
<input type="number" value="${drive.backoff}" min="0" max="10000"
onchange="updateDrive(${drive.id}, 'backoff', parseInt(this.value))">
</div>
</div>
`;
container.appendChild(driveCard);
}
function updateDrive(driveId, field, value) {
const drive = drives.find(d => d.id === driveId);
if (drive) {
drive[field] = value;
}
}
function updateDriveType(driveId, type) {
const drive = drives.find(d => d.id === driveId);
if (drive) {
drive.type = type;
}
}
function removeDrive(driveId) {
const index = drives.findIndex(d => d.id === driveId);
if (index !== -1) {
drives.splice(index, 1);
document.getElementById(`drive-${driveId}`).remove();
drives.forEach((drive, idx) => {
drive.driveNum = idx;
});
document.getElementById('drives-container').innerHTML = '';
drives.forEach(drive => renderDrive(drive));
}
}
function generateConfig() {
const libId = document.getElementById('lib-id').value;
const libChannel = document.getElementById('lib-channel').value;
const libTarget = document.getElementById('lib-target').value;
const libLun = document.getElementById('lib-lun').value;
const libVendor = document.getElementById('lib-vendor').value;
const libProduct = document.getElementById('lib-product').value;
const libSerial = document.getElementById('lib-serial').value;
const libNaa = document.getElementById('lib-naa').value;
const libHome = document.getElementById('lib-home').value;
const libBackoff = document.getElementById('lib-backoff').value;
let config = `VERSION: 5\n\n`;
config += `Library: ${libId} CHANNEL: ${libChannel.padStart(2, '0')} TARGET: ${libTarget.padStart(2, '0')} LUN: ${libLun.padStart(2, '0')}\n`;
config += ` Vendor identification: ${libVendor}\n`;
config += ` Product identification: ${libProduct}\n`;
config += ` Unit serial number: ${libSerial}\n`;
config += ` NAA: ${libNaa}\n`;
config += ` Home directory: ${libHome}\n`;
config += ` Backoff: ${libBackoff}\n`;
drives.forEach(drive => {
const driveInfo = driveTypes[drive.type];
config += `\nDrive: ${drive.driveNum.toString().padStart(2, '0')} CHANNEL: ${drive.channel.toString().padStart(2, '0')} TARGET: ${drive.target.toString().padStart(2, '0')} LUN: ${drive.lun.toString().padStart(2, '0')}\n`;
config += ` Library ID: ${drive.libraryId} Slot: ${drive.slot.toString().padStart(2, '0')}\n`;
config += ` Vendor identification: ${driveInfo.vendor}\n`;
config += ` Product identification: ${driveInfo.product}\n`;
config += ` Unit serial number: ${drive.serial}\n`;
config += ` NAA: ${drive.naa}\n`;
config += ` Compression: factor ${drive.compression} enabled ${drive.compressionEnabled}\n`;
config += ` Compression type: ${drive.compressionType}\n`;
config += ` Backoff: ${drive.backoff}\n`;
});
document.getElementById('config-preview').textContent = config;
const tapeLibrary = document.getElementById('tape-library').value;
const tapeBarcodePrefix = document.getElementById('tape-barcode-prefix').value;
const tapeStartNum = parseInt(document.getElementById('tape-start-num').value);
const tapeSize = document.getElementById('tape-size').value;
const tapeMediaType = document.getElementById('tape-media-type').value;
const tapeDensity = document.getElementById('tape-density').value;
const tapeCount = parseInt(document.getElementById('tape-count').value);
let installCmds = '#!/bin/bash\n';
installCmds += '# Generate virtual tapes for mhvtl\n';
installCmds += '# Run this script after mhvtl installation\n\n';
for (let i = 0; i < tapeCount; i++) {
const barcodeNum = String(tapeStartNum + i).padStart(6, '0');
const barcode = `${tapeBarcodePrefix}${barcodeNum}`;
installCmds += `mktape -l ${tapeLibrary} -m ${barcode} -s ${tapeSize} -t ${tapeMediaType} -d ${tapeDensity}\n`;
}
document.getElementById('install-command').textContent = installCmds;
}
function downloadConfig() {
generateConfig();
const config = document.getElementById('config-preview').textContent;
const blob = new Blob([config], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'device.conf';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showNotification('Configuration downloaded successfully!', 'success');
}
function copyConfig() {
generateConfig();
const config = document.getElementById('config-preview').textContent;
navigator.clipboard.writeText(config).then(() => {
showNotification('Configuration copied to clipboard!', 'success');
}).catch(() => {
showNotification('Failed to copy configuration', 'danger');
});
}
function copyInstallCommand() {
const cmd = document.getElementById('install-command').textContent;
navigator.clipboard.writeText(cmd).then(() => {
showNotification('Command copied to clipboard!', 'success');
}).catch(() => {
showNotification('Failed to copy command', 'danger');
});
}
function generateConfigText() {
const libId = document.getElementById('lib-id').value;
const libChannel = document.getElementById('lib-channel').value;
const libTarget = document.getElementById('lib-target').value;
const libLun = document.getElementById('lib-lun').value;
const libVendor = document.getElementById('lib-vendor').value;
const libProduct = document.getElementById('lib-product').value;
const libSerial = document.getElementById('lib-serial').value;
const libNaa = document.getElementById('lib-naa').value;
const libHome = document.getElementById('lib-home').value;
const libBackoff = document.getElementById('lib-backoff').value;
let config = `VERSION: 5\n\n`;
config += `Library: ${libId} CHANNEL: ${libChannel.padStart(2, '0')} TARGET: ${libTarget.padStart(2, '0')} LUN: ${libLun.padStart(2, '0')}\n`;
config += ` Vendor identification: ${libVendor}\n`;
config += ` Product identification: ${libProduct}\n`;
config += ` Unit serial number: ${libSerial}\n`;
config += ` NAA: ${libNaa}\n`;
config += ` Home directory: ${libHome}\n`;
config += ` Backoff: ${libBackoff}\n`;
drives.forEach(drive => {
const driveInfo = driveTypes[drive.type];
config += `\nDrive: ${drive.driveNum.toString().padStart(2, '0')} CHANNEL: ${drive.channel.toString().padStart(2, '0')} TARGET: ${drive.target.toString().padStart(2, '0')} LUN: ${drive.lun.toString().padStart(2, '0')}\n`;
config += ` Library ID: ${drive.libraryId} Slot: ${drive.slot.toString().padStart(2, '0')}\n`;
config += ` Vendor identification: ${driveInfo.vendor}\n`;
config += ` Product identification: ${driveInfo.product}\n`;
config += ` Unit serial number: ${drive.serial}\n`;
config += ` NAA: ${drive.naa}\n`;
config += ` Compression: factor ${drive.compression} enabled ${drive.compressionEnabled}\n`;
config += ` Compression type: ${drive.compressionType}\n`;
config += ` Backoff: ${drive.backoff}\n`;
});
return config;
}
function applyConfig() {
const config = generateConfigText();
const resultDiv = document.getElementById('apply-result');
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-info';
resultDiv.innerHTML = '<strong>⏳</strong> Applying configuration to server...';
fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'save_config',
config: config
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
resultDiv.className = 'alert alert-success';
resultDiv.innerHTML = `
<strong>✅ Success!</strong> Configuration saved to ${data.file}<br>
<small>Restart mhvtl service to apply changes using the button below.</small>
`;
showNotification('Configuration applied successfully!', 'success');
} else {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${data.error}`;
showNotification('Failed to apply configuration', 'danger');
}
})
.catch(error => {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${error.message}`;
showNotification('Failed to apply configuration', 'danger');
});
}
function restartService() {
const resultDiv = document.getElementById('restart-result');
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-info';
resultDiv.innerHTML = '<strong>⏳</strong> Restarting mhvtl service...';
fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'restart_service'
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
resultDiv.className = 'alert alert-success';
resultDiv.innerHTML = '<strong>✅ Success!</strong> Service restarted successfully';
showNotification('Service restarted successfully!', 'success');
} else {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${data.error}`;
showNotification('Failed to restart service', 'danger');
}
})
.catch(error => {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${error.message}`;
showNotification('Failed to restart service', 'danger');
});
}
function showNotification(message, type) {
const notification = document.createElement('div');
notification.className = `alert alert-${type}`;
notification.style.position = 'fixed';
notification.style.top = '20px';
notification.style.right = '20px';
notification.style.zIndex = '9999';
notification.style.minWidth = '300px';
notification.style.animation = 'slideIn 0.3s ease';
notification.innerHTML = `<strong>${type === 'success' ? '✅' : '❌'}</strong> ${message}`;
document.body.appendChild(notification);
setTimeout(() => {
notification.style.animation = 'slideOut 0.3s ease';
setTimeout(() => {
document.body.removeChild(notification);
}, 300);
}, 3000);
}
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(400px);
opacity: 0;
}
}
`;
document.head.appendChild(style);
let tapeListCache = [];
function loadTapeList() {
const loadingDiv = document.getElementById('tape-list-loading');
const errorDiv = document.getElementById('tape-list-error');
const emptyDiv = document.getElementById('tape-list-empty');
const containerDiv = document.getElementById('tape-list-container');
loadingDiv.style.display = 'block';
errorDiv.style.display = 'none';
emptyDiv.style.display = 'none';
containerDiv.style.display = 'none';
fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'list_tapes'
})
})
.then(response => response.json())
.then(data => {
loadingDiv.style.display = 'none';
if (data.success) {
tapeListCache = data.tapes;
if (data.tapes.length === 0) {
emptyDiv.style.display = 'block';
} else {
containerDiv.style.display = 'block';
renderTapeList(data.tapes);
}
} else {
errorDiv.style.display = 'block';
errorDiv.innerHTML = `<strong>❌ Error:</strong> ${data.error}`;
}
})
.catch(error => {
loadingDiv.style.display = 'none';
errorDiv.style.display = 'block';
errorDiv.innerHTML = `<strong>❌ Error:</strong> ${error.message}`;
});
}
function renderTapeList(tapes) {
const tbody = document.getElementById('tape-list-body');
const countDisplay = document.getElementById('tape-count-display');
tbody.innerHTML = '';
countDisplay.textContent = tapes.length;
tapes.forEach(tape => {
const row = document.createElement('tr');
row.innerHTML = `
<td class="tape-barcode">${tape.name}</td>
<td class="tape-size">${tape.size}</td>
<td class="tape-date">${tape.modified}</td>
<td class="tape-actions">
<button class="btn btn-danger btn-small" onclick="deleteTape('${tape.name}')">
<span>🗑️</span> Delete
</button>
</td>
`;
tbody.appendChild(row);
});
}
function filterTapes() {
const searchTerm = document.getElementById('tape-search').value.toLowerCase();
const filteredTapes = tapeListCache.filter(tape =>
tape.name.toLowerCase().includes(searchTerm)
);
renderTapeList(filteredTapes);
}
function deleteTape(tapeName) {
if (!confirm(`Are you sure you want to delete tape "${tapeName}"?\n\nThis action cannot be undone!`)) {
return;
}
fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'delete_tape',
tape_name: tapeName
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification(`Tape "${tapeName}" deleted successfully!`, 'success');
loadTapeList();
} else {
showNotification(`Failed to delete tape: ${data.error}`, 'danger');
}
})
.catch(error => {
showNotification(`Error: ${error.message}`, 'danger');
});
}
function bulkDeleteTapes() {
const pattern = document.getElementById('bulk-delete-pattern').value.trim();
const resultDiv = document.getElementById('bulk-delete-result');
if (!pattern) {
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = '<strong>❌ Error:</strong> Please enter a delete pattern';
return;
}
if (!confirm(`Are you sure you want to delete all tapes matching "${pattern}"?\n\nThis action cannot be undone!`)) {
return;
}
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-info';
resultDiv.innerHTML = '<strong>⏳</strong> Deleting tapes...';
fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'bulk_delete_tapes',
pattern: pattern
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
resultDiv.className = 'alert alert-success';
resultDiv.innerHTML = `<strong>✅ Success!</strong> Deleted ${data.deleted_count} tape(s)`;
showNotification(`Deleted ${data.deleted_count} tape(s)`, 'success');
loadTapeList();
} else {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${data.error}`;
}
})
.catch(error => {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${error.message}`;
});
}
function createTapes() {
const library = document.getElementById('create-library').value;
const barcodePrefix = document.getElementById('create-barcode-prefix').value.trim();
const startNum = parseInt(document.getElementById('create-start-num').value);
const count = parseInt(document.getElementById('create-count').value);
const size = document.getElementById('create-size').value;
const mediaType = document.getElementById('create-media-type').value;
const density = document.getElementById('create-density').value;
const resultDiv = document.getElementById('create-result');
if (!barcodePrefix) {
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = '<strong>❌ Error:</strong> Barcode prefix is required';
return;
}
if (count < 1 || count > 100) {
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = '<strong>❌ Error:</strong> Number of tapes must be between 1 and 100';
return;
}
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-info';
resultDiv.innerHTML = '<strong>⏳</strong> Creating tapes...';
fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'create_tapes',
library: library,
barcode_prefix: barcodePrefix,
start_num: startNum,
count: count,
size: size,
media_type: mediaType,
density: density
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
resultDiv.className = 'alert alert-success';
resultDiv.innerHTML = `<strong>✅ Success!</strong> Created ${data.created_count} tape(s)`;
if (data.errors && data.errors.length > 0) {
resultDiv.innerHTML += `<br><small>Errors: ${data.errors.join(', ')}</small>`;
}
showNotification(`Created ${data.created_count} tape(s)`, 'success');
loadTapeList();
} else {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${data.error}`;
}
})
.catch(error => {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${error.message}`;
});
}
// ============================================
// iSCSI Target Management Functions
// ============================================
function loadTargets() {
fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'list_targets'
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
const tbody = document.getElementById('target-list-body');
const emptyDiv = document.getElementById('target-list-empty');
const containerDiv = document.getElementById('target-list-container');
if (data.targets.length === 0) {
emptyDiv.style.display = 'block';
containerDiv.style.display = 'none';
} else {
emptyDiv.style.display = 'none';
containerDiv.style.display = 'block';
tbody.innerHTML = data.targets.map(target => `
<tr>
<td><strong>${target.tid}</strong></td>
<td><code>${target.name}</code></td>
<td>${target.luns || 0}</td>
<td>${target.acls || 0}</td>
<td>
<button class="btn btn-danger btn-sm" onclick="deleteTarget(${target.tid})">
🗑️ Delete
</button>
</td>
</tr>
`).join('');
}
} else {
showNotification(data.error, 'error');
}
})
.catch(error => {
showNotification('Failed to load targets: ' + error.message, 'error');
});
}
function createTarget() {
const tid = document.getElementById('target-tid').value;
const name = document.getElementById('target-name').value.trim();
const resultDiv = document.getElementById('create-target-result');
if (!name) {
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = '<strong>❌ Error:</strong> Target name is required';
return;
}
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-info';
resultDiv.innerHTML = '<strong>⏳</strong> Creating target...';
fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'create_target',
tid: tid,
name: name
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
resultDiv.className = 'alert alert-success';
resultDiv.innerHTML = `<strong>✅ Success!</strong> Target created: ${data.iqn}`;
showNotification('Target created successfully', 'success');
loadTargets();
} else {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${data.error}`;
}
})
.catch(error => {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${error.message}`;
});
}
function deleteTarget(tid) {
if (!confirm(`Delete target ${tid}? This will remove all LUNs and ACLs.`)) {
return;
}
fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'delete_target',
tid: tid
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification('Target deleted successfully', 'success');
loadTargets();
} else {
showNotification(data.error, 'error');
}
})
.catch(error => {
showNotification('Failed to delete target: ' + error.message, 'error');
});
}
function addLun() {
const tid = document.getElementById('lun-tid').value;
const lun = document.getElementById('lun-number').value;
const device = document.getElementById('lun-device').value.trim();
const resultDiv = document.getElementById('add-lun-result');
if (!device) {
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = '<strong>❌ Error:</strong> Device path is required';
return;
}
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-info';
resultDiv.innerHTML = '<strong>⏳</strong> Adding LUN...';
fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'add_lun',
tid: tid,
lun: lun,
device: device
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
resultDiv.className = 'alert alert-success';
resultDiv.innerHTML = '<strong>✅ Success!</strong> LUN added successfully';
showNotification('LUN added successfully', 'success');
loadTargets();
} else {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${data.error}`;
}
})
.catch(error => {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${error.message}`;
});
}
function bindInitiator() {
const tid = document.getElementById('acl-tid').value;
const address = document.getElementById('acl-address').value.trim();
const resultDiv = document.getElementById('acl-result');
if (!address) {
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = '<strong>❌ Error:</strong> Initiator address is required';
return;
}
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-info';
resultDiv.innerHTML = '<strong>⏳</strong> Binding initiator...';
fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'bind_initiator',
tid: tid,
address: address
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
resultDiv.className = 'alert alert-success';
resultDiv.innerHTML = '<strong>✅ Success!</strong> Initiator allowed';
showNotification('Initiator allowed successfully', 'success');
loadTargets();
} else {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${data.error}`;
}
})
.catch(error => {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${error.message}`;
});
}
function unbindInitiator() {
const tid = document.getElementById('acl-tid').value;
const address = document.getElementById('acl-address').value.trim();
const resultDiv = document.getElementById('acl-result');
if (!address) {
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = '<strong>❌ Error:</strong> Initiator address is required';
return;
}
if (!confirm(`Block initiator ${address} from target ${tid}?`)) {
return;
}
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-info';
resultDiv.innerHTML = '<strong>⏳</strong> Unbinding initiator...';
fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'unbind_initiator',
tid: tid,
address: address
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
resultDiv.className = 'alert alert-success';
resultDiv.innerHTML = '<strong>✅ Success!</strong> Initiator blocked';
showNotification('Initiator blocked successfully', 'success');
loadTargets();
} else {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${data.error}`;
}
})
.catch(error => {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${error.message}`;
});
}

View File

@@ -0,0 +1,442 @@
:root {
--primary-color: #2563eb;
--secondary-color: #64748b;
--success-color: #10b981;
--danger-color: #ef4444;
--warning-color: #f59e0b;
--info-color: #3b82f6;
--dark-bg: #0f172a;
--card-bg: #1e293b;
--border-color: #334155;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--hover-bg: #334155;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
color: var(--text-primary);
line-height: 1.6;
min-height: 100vh;
}
.navbar {
background: rgba(15, 23, 42, 0.95);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--border-color);
padding: 1rem 0;
position: sticky;
top: 0;
z-index: 1000;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.navbar .container {
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-brand h1 {
font-size: 1.5rem;
color: var(--primary-color);
margin-bottom: 0.25rem;
}
.nav-brand .subtitle {
font-size: 0.875rem;
color: var(--text-secondary);
}
.nav-links {
display: flex;
gap: 1rem;
}
.nav-link {
color: var(--text-secondary);
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
transition: all 0.3s ease;
font-weight: 500;
}
.nav-link:hover {
color: var(--text-primary);
background: var(--hover-bg);
}
.nav-link.active {
color: var(--primary-color);
background: rgba(37, 99, 235, 0.1);
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1.5rem;
}
main.container {
padding: 2rem 1.5rem;
}
.section {
display: none;
animation: fadeIn 0.3s ease;
}
.section.active {
display: block;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.section-header {
margin-bottom: 2rem;
}
.section-header h2 {
font-size: 2rem;
margin-bottom: 0.5rem;
color: var(--text-primary);
}
.section-header p {
color: var(--text-secondary);
font-size: 1.125rem;
}
.card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 0.75rem;
margin-bottom: 1.5rem;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 12px rgba(0, 0, 0, 0.2);
}
.card-header {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border-color);
background: rgba(30, 41, 59, 0.5);
}
.card-header h3 {
font-size: 1.25rem;
color: var(--text-primary);
}
.card-body {
padding: 1.5rem;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.25rem;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group label {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.form-group input,
.form-group select {
padding: 0.75rem;
background: var(--dark-bg);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
color: var(--text-primary);
font-size: 1rem;
transition: all 0.3s ease;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.form-group input:hover,
.form-group select:hover {
border-color: var(--primary-color);
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.btn:active {
transform: translateY(0);
}
.btn-primary {
background: var(--primary-color);
color: white;
}
.btn-primary:hover {
background: #1d4ed8;
}
.btn-success {
background: var(--success-color);
color: white;
}
.btn-success:hover {
background: #059669;
}
.btn-danger {
background: var(--danger-color);
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-secondary {
background: var(--secondary-color);
color: white;
}
.btn-secondary:hover {
background: #475569;
}
.button-group {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1.5rem;
}
.drives-container {
display: grid;
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.drive-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 0.75rem;
padding: 1.5rem;
position: relative;
}
.drive-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.drive-card-header h4 {
color: var(--primary-color);
font-size: 1.125rem;
}
.config-preview {
background: var(--dark-bg);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
padding: 1.5rem;
color: var(--text-primary);
font-family: 'Courier New', monospace;
font-size: 0.875rem;
overflow-x: auto;
white-space: pre-wrap;
line-height: 1.5;
}
.alert {
padding: 1rem 1.25rem;
border-radius: 0.5rem;
margin-top: 1rem;
border-left: 4px solid;
}
.alert-info {
background: rgba(59, 130, 246, 0.1);
border-color: var(--info-color);
color: var(--text-primary);
}
.alert-success {
background: rgba(16, 185, 129, 0.1);
border-color: var(--success-color);
color: var(--text-primary);
}
.alert-warning {
background: rgba(245, 158, 11, 0.1);
border-color: var(--warning-color);
color: var(--text-primary);
}
.alert-danger {
background: rgba(239, 68, 68, 0.1);
border-color: var(--danger-color);
color: var(--text-primary);
}
.footer {
background: rgba(15, 23, 42, 0.95);
border-top: 1px solid var(--border-color);
padding: 2rem 0;
margin-top: 4rem;
text-align: center;
}
.footer p {
color: var(--text-secondary);
font-size: 0.875rem;
}
.footer a {
color: var(--primary-color);
text-decoration: none;
transition: color 0.3s ease;
}
.footer a:hover {
color: #1d4ed8;
}
.tape-table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.tape-table th,
.tape-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.tape-table th {
background: rgba(37, 99, 235, 0.1);
color: var(--primary-color);
font-weight: 600;
text-transform: uppercase;
font-size: 0.875rem;
letter-spacing: 0.05em;
}
.tape-table tbody tr {
transition: background-color 0.2s;
}
.tape-table tbody tr:hover {
background: var(--hover-bg);
}
.tape-table .tape-barcode {
font-family: 'Courier New', monospace;
font-weight: 600;
color: var(--info-color);
}
.tape-table .tape-size {
color: var(--text-secondary);
}
.tape-table .tape-date {
color: var(--text-secondary);
font-size: 0.875rem;
}
.tape-actions {
display: flex;
gap: 0.5rem;
}
.btn-small {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
}
@media (max-width: 768px) {
.navbar .container {
flex-direction: column;
gap: 1rem;
}
.nav-links {
width: 100%;
justify-content: center;
flex-wrap: wrap;
}
.form-grid {
grid-template-columns: 1fr;
}
.button-group {
flex-direction: column;
}
.btn {
width: 100%;
justify-content: center;
}
.tape-table {
font-size: 0.875rem;
}
}

348
install.sh Normal file
View File

@@ -0,0 +1,348 @@
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
INSTALL_DIR="/opt/adastra-vtl"
WEB_DIR="/var/www/html/mhvtl-config"
SYSTEMD_DIR="/etc/systemd/system"
CONFIG_DIR="/etc/mhvtl"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
print_header() {
echo -e "${BLUE}╔════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ Adastra VTL Installer v1.0 ║${NC}"
echo -e "${BLUE}║ Virtual Tape Library System ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════╝${NC}"
echo ""
}
print_success() {
echo -e "${GREEN}${NC} $1"
}
print_error() {
echo -e "${RED}${NC} $1"
}
print_info() {
echo -e "${YELLOW}${NC} $1"
}
check_root() {
if [ "$EUID" -ne 0 ]; then
print_error "Please run as root or with sudo"
exit 1
fi
}
detect_distro() {
if [ -f /etc/os-release ]; then
. /etc/os-release
DISTRO=$ID
VERSION=$VERSION_ID
elif [ -f /etc/redhat-release ]; then
DISTRO="rhel"
elif [ -f /etc/debian_version ]; then
DISTRO="debian"
else
print_error "Unable to detect Linux distribution"
exit 1
fi
print_info "Detected: $DISTRO $VERSION"
}
install_dependencies_debian() {
print_info "Installing dependencies for Debian/Ubuntu..."
apt-get update -qq
DEBIAN_PACKAGES=(
"build-essential"
"git"
"zlib1g-dev"
"lsscsi"
"mt-st"
"mtx"
"lsof"
"sg3-utils"
"apache2"
"php"
"libapache2-mod-php"
)
apt-get install -y "${DEBIAN_PACKAGES[@]}"
systemctl enable apache2
systemctl start apache2
print_success "Dependencies installed (Debian/Ubuntu)"
}
install_dependencies_rpm() {
print_info "Installing dependencies for RHEL/CentOS/Fedora..."
if command -v dnf &> /dev/null; then
PKG_MGR="dnf"
else
PKG_MGR="yum"
fi
RPM_PACKAGES=(
"gcc"
"gcc-c++"
"make"
"git"
"zlib-devel"
"lsscsi"
"mt-st"
"mtx"
"lsof"
"sg3_utils"
"httpd"
"php"
)
$PKG_MGR install -y "${RPM_PACKAGES[@]}"
systemctl enable httpd
systemctl start httpd
if command -v firewall-cmd &> /dev/null; then
firewall-cmd --permanent --add-service=http
firewall-cmd --reload
fi
if command -v setenforce &> /dev/null; then
setenforce 0 || true
sed -i 's/^SELINUX=enforcing/SELINUX=permissive/' /etc/selinux/config 2>/dev/null || true
fi
print_success "Dependencies installed (RHEL/CentOS/Fedora)"
}
install_mhvtl() {
print_info "Installing mhvtl from source..."
cd /tmp
if [ -d "mhvtl" ]; then
rm -rf mhvtl
fi
git clone https://github.com/markh794/mhvtl.git
cd mhvtl
make
make install
if [ ! -d "$CONFIG_DIR" ]; then
mkdir -p "$CONFIG_DIR"
fi
if [ -f "kernel/mhvtl.ko" ]; then
cp kernel/*.ko /lib/modules/$(uname -r)/kernel/drivers/scsi/ 2>/dev/null || true
depmod -a
fi
cd /tmp
rm -rf mhvtl
print_success "mhvtl installed"
}
install_adastra_vtl() {
print_info "Installing Adastra VTL..."
if [ -d "$INSTALL_DIR" ]; then
print_info "Removing old installation..."
rm -rf "$INSTALL_DIR"
fi
mkdir -p "$INSTALL_DIR"
print_info "Copying scripts..."
if [ -d "$SCRIPT_DIR/scripts" ]; then
cp -r "$SCRIPT_DIR/scripts" "$INSTALL_DIR/"
chmod +x "$INSTALL_DIR/scripts"/*.sh
print_success "Scripts copied"
else
print_error "Scripts directory not found: $SCRIPT_DIR/scripts"
fi
print_info "Copying documentation..."
if [ -d "$SCRIPT_DIR/docs" ]; then
cp -r "$SCRIPT_DIR/docs" "$INSTALL_DIR/"
print_success "Documentation copied"
fi
if [ -f "$SCRIPT_DIR/README.md" ]; then
cp "$SCRIPT_DIR/README.md" "$INSTALL_DIR/"
fi
print_info "Copying configuration templates..."
if [ -d "$SCRIPT_DIR/config" ]; then
mkdir -p "$CONFIG_DIR"
for file in "$SCRIPT_DIR/config"/*; do
if [ -f "$file" ]; then
filename=$(basename "$file")
if [ ! -f "$CONFIG_DIR/$filename" ]; then
cp "$file" "$CONFIG_DIR/"
fi
fi
done
print_success "Configuration templates copied"
fi
print_info "Deploying Web UI..."
if [ -d "$SCRIPT_DIR/web-ui" ]; then
mkdir -p "$WEB_DIR"
cp -r "$SCRIPT_DIR/web-ui"/* "$WEB_DIR/"
if [ "$DISTRO" = "debian" ] || [ "$DISTRO" = "ubuntu" ]; then
chown -R www-data:www-data "$WEB_DIR"
else
chown -R apache:apache "$WEB_DIR"
fi
chmod -R 755 "$WEB_DIR"
print_success "Web UI deployed to $WEB_DIR"
else
print_error "Web UI directory not found: $SCRIPT_DIR/web-ui"
fi
print_info "Installing systemd services..."
if [ -d "$SCRIPT_DIR/systemd" ]; then
cp "$SCRIPT_DIR/systemd"/*.service "$SYSTEMD_DIR/"
systemctl daemon-reload
print_success "Systemd services installed"
else
print_error "Systemd directory not found: $SCRIPT_DIR/systemd"
fi
print_info "Configuring sudoers for web UI..."
if [ -f "$SCRIPT_DIR/config/mhvtl-sudoers" ]; then
cp "$SCRIPT_DIR/config/mhvtl-sudoers" /etc/sudoers.d/mhvtl
chmod 440 /etc/sudoers.d/mhvtl
print_success "Sudoers configured"
fi
print_success "Adastra VTL installed to $INSTALL_DIR"
}
configure_system() {
print_info "Configuring system..."
if ! grep -q "^vtl:" /etc/group; then
groupadd vtl
fi
if ! id -u vtl &>/dev/null; then
useradd -r -g vtl -s /bin/bash -d /var/lib/mhvtl vtl
fi
mkdir -p /var/lib/mhvtl
chown -R vtl:vtl /var/lib/mhvtl
mkdir -p /opt/mhvtl
chown -R vtl:vtl /opt/mhvtl
chmod 775 /opt/mhvtl
mkdir -p "$CONFIG_DIR/backups"
chown -R vtl:vtl "$CONFIG_DIR"
chmod 775 "$CONFIG_DIR"
chmod 775 "$CONFIG_DIR/backups"
chmod 664 "$CONFIG_DIR"/*.conf 2>/dev/null || true
if [ "$DISTRO" = "debian" ] || [ "$DISTRO" = "ubuntu" ]; then
usermod -a -G vtl www-data
systemctl restart apache2 2>/dev/null || true
else
usermod -a -G vtl apache
systemctl restart httpd 2>/dev/null || true
fi
if [ -f "$INSTALL_DIR/scripts/load-mhvtl.sh" ]; then
ln -sf "$INSTALL_DIR/scripts/load-mhvtl.sh" /usr/local/bin/mhvtl-load
fi
if [ -f "$INSTALL_DIR/scripts/unload-mhvtl.sh" ]; then
ln -sf "$INSTALL_DIR/scripts/unload-mhvtl.sh" /usr/local/bin/mhvtl-unload
fi
print_success "System configured"
}
print_completion() {
echo ""
echo -e "${GREEN}╔════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ Installation Complete! ║${NC}"
echo -e "${GREEN}╚════════════════════════════════════════╝${NC}"
echo ""
echo -e "${BLUE}Installation Details:${NC}"
echo -e " • Install directory: ${YELLOW}$INSTALL_DIR${NC}"
echo -e " • Web UI: ${YELLOW}http://$(hostname -I | awk '{print $1}')/mhvtl-config${NC}"
echo -e " • Config directory: ${YELLOW}$CONFIG_DIR${NC}"
echo ""
echo -e "${BLUE}Quick Start:${NC}"
echo -e " 1. Load mhvtl kernel module:"
echo -e " ${YELLOW}mhvtl-load${NC}"
echo ""
echo -e " 2. Configure via Web UI or edit:"
echo -e " ${YELLOW}$CONFIG_DIR/device.conf${NC}"
echo ""
echo -e " 3. Start mhvtl service:"
echo -e " ${YELLOW}systemctl start mhvtl${NC}"
echo ""
echo -e " 4. Enable on boot:"
echo -e " ${YELLOW}systemctl enable mhvtl${NC}"
echo ""
echo -e "${BLUE}Useful Commands:${NC}"
echo -e " • Load modules: ${YELLOW}mhvtl-load${NC}"
echo -e " • Unload modules: ${YELLOW}mhvtl-unload${NC}"
echo -e " • Check status: ${YELLOW}systemctl status mhvtl${NC}"
echo -e " • View devices: ${YELLOW}lsscsi -g${NC}"
echo ""
}
main() {
print_header
check_root
detect_distro
case $DISTRO in
ubuntu|debian|linuxmint|pop)
install_dependencies_debian
;;
rhel|centos|fedora|rocky|almalinux)
install_dependencies_rpm
;;
*)
print_error "Unsupported distribution: $DISTRO"
print_info "Supported: Debian, Ubuntu, RHEL, CentOS, Fedora, Rocky, AlmaLinux"
exit 1
;;
esac
install_mhvtl
install_adastra_vtl
configure_system
print_info "Fixing mhvtl configuration permissions..."
chmod 664 "$CONFIG_DIR"/*.conf 2>/dev/null || true
print_success "Permissions fixed"
print_completion
}
main "$@"

47
scripts/load-mhvtl.sh Executable file
View File

@@ -0,0 +1,47 @@
#!/bin/bash
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
print_info() {
echo -e "${YELLOW}${NC} $1"
}
print_success() {
echo -e "${GREEN}${NC} $1"
}
print_error() {
echo -e "${RED}${NC} $1"
}
if [ "$EUID" -ne 0 ]; then
print_error "Please run as root or with sudo"
exit 1
fi
print_info "Loading mhvtl kernel modules..."
if lsmod | grep -q mhvtl; then
print_info "mhvtl modules already loaded"
else
if [ -f /lib/modules/$(uname -r)/kernel/drivers/scsi/mhvtl.ko ]; then
modprobe mhvtl
print_success "mhvtl kernel module loaded"
else
print_info "Kernel module not found, using userspace mode"
fi
fi
if [ -f /usr/bin/vtllibrary ]; then
print_success "mhvtl is ready"
echo ""
print_info "Start mhvtl daemon with: systemctl start mhvtl"
else
print_error "mhvtl binaries not found"
exit 1
fi

46
scripts/start-mhvtl.sh Executable file
View File

@@ -0,0 +1,46 @@
#!/bin/bash
CONFIG_FILE="/etc/mhvtl/device.conf"
if [ ! -f "$CONFIG_FILE" ]; then
echo "Error: Configuration file not found: $CONFIG_FILE"
exit 1
fi
echo "Starting mhvtl Virtual Tape Library..."
rm -f /var/lock/mhvtl/mhvtl* 2>/dev/null || true
modprobe mhvtl 2>/dev/null || echo "Note: Running in userspace mode (kernel module not available)"
sleep 1
DRIVE_NUMS=$(grep "^Drive:" "$CONFIG_FILE" | awk '{print $2}' | sort -u)
for drive in $DRIVE_NUMS; do
if ! pgrep -f "vtltape.*-q $drive" > /dev/null; then
echo "Starting vtltape for drive $drive..."
/usr/bin/vtltape -q $drive 2>&1 | grep -v "Could not locate mhvtl kernel module" || true
else
echo "vtltape for drive $drive is already running"
fi
done
sleep 2
LIBRARY_NUMS=$(grep "^Library:" "$CONFIG_FILE" | awk '{print $2}' | sort -u)
for library in $LIBRARY_NUMS; do
if ! pgrep -f "vtllibrary.*$library" > /dev/null; then
echo "Starting vtllibrary for library $library..."
/usr/bin/vtllibrary $library 2>&1 || echo "Warning: Failed to start vtllibrary for library $library"
else
echo "vtllibrary for library $library is already running"
fi
done
RUNNING_DRIVES=$(pgrep -f "vtltape" | wc -l)
RUNNING_LIBS=$(pgrep -f "vtllibrary" | wc -l)
echo "mhvtl started: $RUNNING_DRIVES drives, $RUNNING_LIBS libraries"
exit 0

15
scripts/stop-mhvtl.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/bash
echo "Stopping mhvtl Virtual Tape Library..."
killall vtllibrary 2>/dev/null || true
killall vtltape 2>/dev/null || true
sleep 2
rm -f /var/lock/mhvtl/mhvtl* 2>/dev/null || true
rmmod mhvtl 2>/dev/null || true
echo "mhvtl stopped successfully"
exit 0

39
scripts/unload-mhvtl.sh Executable file
View File

@@ -0,0 +1,39 @@
#!/bin/bash
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
print_info() {
echo -e "${YELLOW}${NC} $1"
}
print_success() {
echo -e "${GREEN}${NC} $1"
}
print_error() {
echo -e "${RED}${NC} $1"
}
if [ "$EUID" -ne 0 ]; then
print_error "Please run as root or with sudo"
exit 1
fi
print_info "Stopping mhvtl services..."
systemctl stop mhvtl 2>/dev/null || true
print_info "Unloading mhvtl kernel modules..."
if lsmod | grep -q mhvtl; then
rmmod mhvtl 2>/dev/null || true
print_success "mhvtl kernel module unloaded"
else
print_info "mhvtl modules not loaded"
fi
print_success "mhvtl unloaded"

View File

@@ -1,15 +1,18 @@
[Unit] [Unit]
Description=mhvtl Virtual Tape Library Description=mhvtl Virtual Tape Library
After=network.target After=network.target
Documentation=man:vtltape(1) man:vtllibrary(1)
[Service] [Service]
Type=forking Type=forking
ExecStartPre=/sbin/modprobe mhvtl ExecStart=/opt/adastra-vtl/scripts/start-mhvtl.sh
ExecStart=/usr/bin/vtltape ExecStop=/opt/adastra-vtl/scripts/stop-mhvtl.sh
ExecStart=/usr/bin/vtllibrary RemainAfterExit=yes
ExecStop=/usr/bin/killall vtltape vtllibrary
Restart=on-failure Restart=on-failure
RestartSec=5s RestartSec=10s
User=root
StandardOutput=journal
StandardError=journal
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

88
uninstall.sh Normal file
View File

@@ -0,0 +1,88 @@
#!/bin/bash
set -e
INSTALL_DIR="/opt/adastra-vtl"
WEB_DIR="/var/www/html/mhvtl-config"
SYSTEMD_DIR="/etc/systemd/system"
CONFIG_DIR="/etc/mhvtl"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
print_info() {
echo -e "${YELLOW}${NC} $1"
}
print_success() {
echo -e "${GREEN}${NC} $1"
}
print_error() {
echo -e "${RED}${NC} $1"
}
check_root() {
if [ "$EUID" -ne 0 ]; then
print_error "Please run as root or with sudo"
exit 1
fi
}
uninstall_adastra() {
print_info "Stopping services..."
systemctl stop mhvtl 2>/dev/null || true
systemctl disable mhvtl 2>/dev/null || true
print_info "Unloading kernel modules..."
if [ -f /usr/local/bin/mhvtl-unload ]; then
/usr/local/bin/mhvtl-unload 2>/dev/null || true
fi
print_info "Removing files..."
rm -rf "$INSTALL_DIR"
rm -rf "$WEB_DIR"
rm -f "$SYSTEMD_DIR"/mhvtl*.service
rm -f /usr/local/bin/mhvtl-load
rm -f /usr/local/bin/mhvtl-unload
systemctl daemon-reload
print_info "Removing mhvtl..."
rm -f /usr/bin/vtl*
rm -f /usr/bin/mktape
rm -f /lib/modules/$(uname -r)/kernel/drivers/scsi/mhvtl.ko 2>/dev/null || true
depmod -a
print_success "Adastra VTL uninstalled"
echo ""
print_info "Note: Configuration files in $CONFIG_DIR were preserved"
print_info "Note: User 'vtl' and group 'vtl' were preserved"
print_info "To remove them manually:"
echo " userdel vtl"
echo " groupdel vtl"
echo " rm -rf $CONFIG_DIR"
echo " rm -rf /var/lib/mhvtl"
}
main() {
echo "Adastra VTL Uninstaller"
echo ""
check_root
read -p "Are you sure you want to uninstall Adastra VTL? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
uninstall_adastra
else
print_info "Uninstall cancelled"
exit 0
fi
}
main "$@"

65
web-ui/README.md Normal file
View File

@@ -0,0 +1,65 @@
# mhvtl Configuration Web UI
Web-based configuration manager for mhvtl (Virtual Tape Library).
## Features
- 📚 Library configuration
- 💾 Drive management (add/remove/configure)
- 📼 Tape generation settings
- 📤 Export configuration files
- 🎨 Modern UI with Adastra theme
## Usage
### Local Development
Simply open `index.html` in your web browser:
```bash
cd web-ui
python3 -m http.server 8080
# or
php -S localhost:8080
```
Then open http://localhost:8080 in your browser.
### Deploy to VTL System
Copy the web-ui directory to your VTL system:
```bash
scp -r web-ui/ root@vtl-server:/var/www/html/mhvtl-config/
```
Or include it in the ISO build by adding to the build script.
## Configuration Workflow
1. **Library Tab**: Configure library settings (ID, vendor, serial, etc.)
2. **Drives Tab**: Add/remove drives and configure each drive
3. **Tapes Tab**: Set tape generation parameters
4. **Export Tab**:
- Generate configuration preview
- Download `device.conf` file
- Copy mktape command for tape generation
## Generated Files
- `device.conf` - Main mhvtl configuration file (goes to `/etc/mhvtl/`)
- `mktape` command - Run this to generate virtual tapes
## Integration with Build
To include this in the ISO build, add to `build/build-iso.sh`:
```bash
# Copy web UI
mkdir -p "$WORK_DIR/chroot/var/www/html"
cp -r web-ui "$WORK_DIR/chroot/var/www/html/mhvtl-config"
```
## Customization
Edit `style.css` to customize colors and theme. Current theme matches adastra.id branding.

655
web-ui/api.php Normal file
View File

@@ -0,0 +1,655 @@
<?php
header('Content-Type: application/json');
// Configuration
$CONFIG_DIR = '/etc/mhvtl';
$DEVICE_CONF = $CONFIG_DIR . '/device.conf';
$BACKUP_DIR = $CONFIG_DIR . '/backups';
// Ensure backup directory exists
if (!is_dir($BACKUP_DIR)) {
mkdir($BACKUP_DIR, 0755, true);
}
// Get POST data
$input = json_decode(file_get_contents('php://input'), true);
if (!$input || !isset($input['action'])) {
echo json_encode(['success' => false, 'error' => 'Invalid request']);
exit;
}
$action = $input['action'];
switch ($action) {
case 'save_config':
saveConfig($input['config']);
break;
case 'load_config':
loadConfig();
break;
case 'restart_service':
restartService();
break;
case 'list_tapes':
listTapes();
break;
case 'delete_tape':
deleteTape($input['tape_name']);
break;
case 'bulk_delete_tapes':
bulkDeleteTapes($input['pattern']);
break;
case 'create_tapes':
createTapes($input);
break;
case 'list_targets':
listTargets();
break;
case 'create_target':
createTarget($input);
break;
case 'delete_target':
deleteTarget($input['tid']);
break;
case 'add_lun':
addLun($input);
break;
case 'bind_initiator':
bindInitiator($input);
break;
case 'unbind_initiator':
unbindInitiator($input);
break;
default:
echo json_encode(['success' => false, 'error' => 'Unknown action']);
}
// ============================================
// iSCSI Target Management Functions
// ============================================
function listTargets() {
$output = [];
$returnCode = 0;
exec('sudo tgtadm --lld iscsi --mode target --op show 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
echo json_encode([
'success' => false,
'error' => 'Failed to list targets: ' . implode(' ', $output)
]);
return;
}
$targets = [];
$currentTarget = null;
$inACLSection = false;
foreach ($output as $line) {
if (preg_match('/^Target (\d+): (.+)$/', $line, $matches)) {
if ($currentTarget) {
$targets[] = $currentTarget;
}
$currentTarget = [
'tid' => intval($matches[1]),
'name' => trim($matches[2]),
'luns' => 0,
'acls' => 0
];
$inACLSection = false;
} elseif ($currentTarget && preg_match('/^\s+LUN: (\d+)/', $line)) {
$currentTarget['luns']++;
$inACLSection = false;
} elseif ($currentTarget && preg_match('/^\s+ACL information:/', $line)) {
$inACLSection = true;
} elseif ($currentTarget && $inACLSection && preg_match('/^\s+(.+)$/', $line, $matches)) {
$acl = trim($matches[1]);
if (!empty($acl) && !preg_match('/^(Account|I_T nexus|LUN|System)/', $acl)) {
$currentTarget['acls']++;
}
}
}
if ($currentTarget) {
$targets[] = $currentTarget;
}
echo json_encode([
'success' => true,
'targets' => $targets
]);
}
function createTarget($params) {
$tid = isset($params['tid']) ? intval($params['tid']) : 0;
$name = isset($params['name']) ? trim($params['name']) : '';
if ($tid <= 0) {
echo json_encode(['success' => false, 'error' => 'Invalid TID']);
return;
}
if (empty($name)) {
echo json_encode(['success' => false, 'error' => 'Target name is required']);
return;
}
if (!preg_match('/^[a-zA-Z0-9._-]+$/', $name)) {
echo json_encode(['success' => false, 'error' => 'Invalid target name format']);
return;
}
$iqn = "iqn.2024-01.com.vtl-linux:$name";
$command = sprintf(
'sudo tgtadm --lld iscsi --mode target --op new --tid %d --targetname %s 2>&1',
$tid,
escapeshellarg($iqn)
);
$output = [];
$returnCode = 0;
exec($command, $output, $returnCode);
if ($returnCode === 0) {
echo json_encode([
'success' => true,
'message' => 'Target created successfully',
'iqn' => $iqn
]);
} else {
echo json_encode([
'success' => false,
'error' => 'Failed to create target: ' . implode(' ', $output)
]);
}
}
function deleteTarget($tid) {
$tid = intval($tid);
if ($tid <= 0) {
echo json_encode(['success' => false, 'error' => 'Invalid TID']);
return;
}
$command = sprintf(
'sudo tgtadm --lld iscsi --mode target --op delete --force --tid %d 2>&1',
$tid
);
$output = [];
$returnCode = 0;
exec($command, $output, $returnCode);
if ($returnCode === 0) {
echo json_encode([
'success' => true,
'message' => 'Target deleted successfully'
]);
} else {
echo json_encode([
'success' => false,
'error' => 'Failed to delete target: ' . implode(' ', $output)
]);
}
}
function addLun($params) {
$tid = isset($params['tid']) ? intval($params['tid']) : 0;
$lun = isset($params['lun']) ? intval($params['lun']) : 0;
$device = isset($params['device']) ? trim($params['device']) : '';
if ($tid <= 0) {
echo json_encode(['success' => false, 'error' => 'Invalid TID']);
return;
}
if ($lun < 0) {
echo json_encode(['success' => false, 'error' => 'Invalid LUN number']);
return;
}
if (empty($device)) {
echo json_encode(['success' => false, 'error' => 'Device path is required']);
return;
}
if (!preg_match('#^/dev/(sg\d+|sd[a-z]+)$#', $device)) {
echo json_encode(['success' => false, 'error' => 'Invalid device path']);
return;
}
if (!file_exists($device)) {
echo json_encode(['success' => false, 'error' => 'Device does not exist']);
return;
}
$command = sprintf(
'sudo tgtadm --lld iscsi --mode logicalunit --op new --tid %d --lun %d --backing-store %s 2>&1',
$tid,
$lun,
escapeshellarg($device)
);
$output = [];
$returnCode = 0;
exec($command, $output, $returnCode);
if ($returnCode === 0) {
echo json_encode([
'success' => true,
'message' => 'LUN added successfully'
]);
} else {
echo json_encode([
'success' => false,
'error' => 'Failed to add LUN: ' . implode(' ', $output)
]);
}
}
function bindInitiator($params) {
$tid = isset($params['tid']) ? intval($params['tid']) : 0;
$address = isset($params['address']) ? trim($params['address']) : '';
if ($tid <= 0) {
echo json_encode(['success' => false, 'error' => 'Invalid TID']);
return;
}
if (empty($address)) {
echo json_encode(['success' => false, 'error' => 'Initiator address is required']);
return;
}
if ($address !== 'ALL' && !filter_var($address, FILTER_VALIDATE_IP)) {
echo json_encode(['success' => false, 'error' => 'Invalid IP address']);
return;
}
$command = sprintf(
'sudo tgtadm --lld iscsi --mode target --op bind --tid %d --initiator-address %s 2>&1',
$tid,
escapeshellarg($address)
);
$output = [];
$returnCode = 0;
exec($command, $output, $returnCode);
if ($returnCode === 0) {
echo json_encode([
'success' => true,
'message' => 'Initiator allowed successfully'
]);
} else {
echo json_encode([
'success' => false,
'error' => 'Failed to bind initiator: ' . implode(' ', $output)
]);
}
}
function unbindInitiator($params) {
$tid = isset($params['tid']) ? intval($params['tid']) : 0;
$address = isset($params['address']) ? trim($params['address']) : '';
if ($tid <= 0) {
echo json_encode(['success' => false, 'error' => 'Invalid TID']);
return;
}
if (empty($address)) {
echo json_encode(['success' => false, 'error' => 'Initiator address is required']);
return;
}
if ($address !== 'ALL' && !filter_var($address, FILTER_VALIDATE_IP)) {
echo json_encode(['success' => false, 'error' => 'Invalid IP address']);
return;
}
$command = sprintf(
'sudo tgtadm --lld iscsi --mode target --op unbind --tid %d --initiator-address %s 2>&1',
$tid,
escapeshellarg($address)
);
$output = [];
$returnCode = 0;
exec($command, $output, $returnCode);
if ($returnCode === 0) {
echo json_encode([
'success' => true,
'message' => 'Initiator blocked successfully'
]);
} else {
echo json_encode([
'success' => false,
'error' => 'Failed to unbind initiator: ' . implode(' ', $output)
]);
}
}
function createTapes($params) {
$library = isset($params['library']) ? intval($params['library']) : 10;
$barcodePrefix = isset($params['barcode_prefix']) ? trim($params['barcode_prefix']) : '';
$startNum = isset($params['start_num']) ? intval($params['start_num']) : 0;
$count = isset($params['count']) ? intval($params['count']) : 1;
$size = isset($params['size']) ? intval($params['size']) : 2500000;
$mediaType = isset($params['media_type']) ? $params['media_type'] : 'data';
$density = isset($params['density']) ? $params['density'] : 'LTO6';
if (empty($barcodePrefix)) {
echo json_encode(['success' => false, 'error' => 'Barcode prefix is required']);
return;
}
if ($count < 1 || $count > 100) {
echo json_encode(['success' => false, 'error' => 'Count must be between 1 and 100']);
return;
}
if (strlen($barcodePrefix) > 6) {
echo json_encode(['success' => false, 'error' => 'Barcode prefix too long (max 6 chars)']);
return;
}
$validMediaTypes = ['data', 'clean', 'WORM'];
if (!in_array($mediaType, $validMediaTypes)) {
echo json_encode(['success' => false, 'error' => 'Invalid media type']);
return;
}
$validDensities = ['LTO5', 'LTO6', 'LTO7', 'LTO8', 'LTO9'];
if (!in_array($density, $validDensities)) {
echo json_encode(['success' => false, 'error' => 'Invalid density']);
return;
}
$createdCount = 0;
$errors = [];
for ($i = 0; $i < $count; $i++) {
$barcodeNum = str_pad($startNum + $i, 6, '0', STR_PAD_LEFT);
$barcode = $barcodePrefix . $barcodeNum;
$command = sprintf(
'mktape -l %d -m %s -s %d -t %s -d %s 2>&1',
$library,
escapeshellarg($barcode),
$size,
escapeshellarg($mediaType),
escapeshellarg($density)
);
$output = [];
$returnCode = 0;
exec($command, $output, $returnCode);
if ($returnCode === 0) {
$createdCount++;
} else {
$errors[] = $barcode . ': ' . implode(' ', $output);
}
}
if ($createdCount > 0) {
$response = [
'success' => true,
'created_count' => $createdCount,
'message' => "Created $createdCount tape(s)"
];
if (!empty($errors)) {
$response['errors'] = $errors;
}
echo json_encode($response);
} else {
echo json_encode([
'success' => false,
'error' => 'Failed to create tapes: ' . implode('; ', $errors)
]);
}
}
function saveConfig($config) {
global $DEVICE_CONF, $BACKUP_DIR;
if (empty($config)) {
echo json_encode(['success' => false, 'error' => 'Empty configuration']);
return;
}
// Create backup of existing config
if (file_exists($DEVICE_CONF)) {
$backupFile = $BACKUP_DIR . '/device.conf.' . date('Y-m-d_H-i-s');
if (!copy($DEVICE_CONF, $backupFile)) {
echo json_encode(['success' => false, 'error' => 'Failed to create backup']);
return;
}
}
// Write new config
if (file_put_contents($DEVICE_CONF, $config) === false) {
echo json_encode(['success' => false, 'error' => 'Failed to write configuration file. Check permissions.']);
return;
}
// Set proper permissions
chmod($DEVICE_CONF, 0644);
echo json_encode([
'success' => true,
'file' => $DEVICE_CONF,
'backup' => isset($backupFile) ? $backupFile : null,
'message' => 'Configuration saved successfully'
]);
}
function loadConfig() {
global $DEVICE_CONF;
if (!file_exists($DEVICE_CONF)) {
echo json_encode(['success' => false, 'error' => 'Configuration file not found']);
return;
}
$config = file_get_contents($DEVICE_CONF);
echo json_encode([
'success' => true,
'config' => $config
]);
}
function restartService() {
// Check if user has sudo privileges
$output = [];
$returnCode = 0;
exec('sudo systemctl restart mhvtl 2>&1', $output, $returnCode);
if ($returnCode === 0) {
echo json_encode([
'success' => true,
'message' => 'Service restarted successfully'
]);
} else {
echo json_encode([
'success' => false,
'error' => 'Failed to restart service: ' . implode("\n", $output)
]);
}
}
function listTapes() {
$tapeDir = '/opt/mhvtl';
if (!is_dir($tapeDir)) {
echo json_encode(['success' => false, 'error' => 'Tape directory not found']);
return;
}
$tapes = [];
$items = scandir($tapeDir);
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $tapeDir . '/' . $item;
if (is_dir($path)) {
$stat = stat($path);
$size = getDirSize($path);
$tapes[] = [
'name' => $item,
'size' => formatBytes($size),
'modified' => date('Y-m-d H:i:s', $stat['mtime'])
];
}
}
usort($tapes, function($a, $b) {
return strcmp($a['name'], $b['name']);
});
echo json_encode([
'success' => true,
'tapes' => $tapes
]);
}
function getDirSize($dir) {
$size = 0;
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)) as $file) {
$size += $file->getSize();
}
return $size;
}
function formatBytes($bytes) {
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= pow(1024, $pow);
return round($bytes, 2) . ' ' . $units[$pow];
}
function deleteTape($tapeName) {
$tapeDir = '/opt/mhvtl';
$tapePath = $tapeDir . '/' . basename($tapeName);
if (!file_exists($tapePath)) {
echo json_encode(['success' => false, 'error' => 'Tape not found']);
return;
}
if (!is_dir($tapePath)) {
echo json_encode(['success' => false, 'error' => 'Invalid tape path']);
return;
}
if (strpos(realpath($tapePath), realpath($tapeDir)) !== 0) {
echo json_encode(['success' => false, 'error' => 'Security violation: Path traversal detected']);
return;
}
$output = [];
$returnCode = 0;
exec('sudo rm -rf ' . escapeshellarg($tapePath) . ' 2>&1', $output, $returnCode);
if ($returnCode === 0) {
echo json_encode([
'success' => true,
'message' => 'Tape deleted successfully'
]);
} else {
echo json_encode([
'success' => false,
'error' => 'Failed to delete tape: ' . implode("\n", $output)
]);
}
}
function bulkDeleteTapes($pattern) {
$tapeDir = '/opt/mhvtl';
if (empty($pattern)) {
echo json_encode(['success' => false, 'error' => 'Pattern is required']);
return;
}
if (strpos($pattern, '/') !== false || strpos($pattern, '..') !== false) {
echo json_encode(['success' => false, 'error' => 'Invalid pattern']);
return;
}
$items = glob($tapeDir . '/' . $pattern, GLOB_ONLYDIR);
if (empty($items)) {
echo json_encode([
'success' => true,
'deleted_count' => 0,
'message' => 'No tapes found matching pattern'
]);
return;
}
$deletedCount = 0;
$errors = [];
foreach ($items as $item) {
if (strpos(realpath($item), realpath($tapeDir)) !== 0) {
continue;
}
$output = [];
$returnCode = 0;
exec('sudo rm -rf ' . escapeshellarg($item) . ' 2>&1', $output, $returnCode);
if ($returnCode === 0) {
$deletedCount++;
} else {
$errors[] = basename($item) . ': ' . implode(' ', $output);
}
}
if ($deletedCount > 0) {
$message = "Deleted $deletedCount tape(s)";
if (!empty($errors)) {
$message .= '. Errors: ' . implode('; ', $errors);
}
echo json_encode([
'success' => true,
'deleted_count' => $deletedCount,
'message' => $message
]);
} else {
echo json_encode([
'success' => false,
'error' => 'Failed to delete tapes: ' . implode('; ', $errors)
]);
}
}
?>

458
web-ui/index.html Normal file
View File

@@ -0,0 +1,458 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>mhvtl Configuration Manager - Adastra VTL</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<nav class="navbar">
<div class="container">
<div class="nav-brand">
<h1>🎞️ Adastra VTL</h1>
<span class="subtitle">Virtual Tape Library Configuration</span>
</div>
<div class="nav-links">
<a href="#library" class="nav-link active">Library</a>
<a href="#drives" class="nav-link">Drives</a>
<a href="#tapes" class="nav-link">Tapes</a>
<a href="#manage-tapes" class="nav-link">Manage Tapes</a>
<a href="#iscsi" class="nav-link">iSCSI</a>
<a href="#export" class="nav-link">Export</a>
</div>
</div>
</nav>
<main class="container">
<section id="library" class="section active">
<div class="section-header">
<h2>📚 Library Configuration</h2>
<p>Configure your virtual tape library settings</p>
</div>
<div class="card">
<div class="card-header">
<h3>Library Settings</h3>
</div>
<div class="card-body">
<div class="form-grid">
<div class="form-group">
<label for="lib-id">Library ID</label>
<input type="number" id="lib-id" value="10" min="0" max="99">
</div>
<div class="form-group">
<label for="lib-channel">Channel</label>
<input type="number" id="lib-channel" value="0" min="0" max="15">
</div>
<div class="form-group">
<label for="lib-target">Target</label>
<input type="number" id="lib-target" value="0" min="0" max="15">
</div>
<div class="form-group">
<label for="lib-lun">LUN</label>
<input type="number" id="lib-lun" value="0" min="0" max="7">
</div>
<div class="form-group">
<label for="lib-vendor">Vendor</label>
<input type="text" id="lib-vendor" value="STK" maxlength="8">
</div>
<div class="form-group">
<label for="lib-product">Product</label>
<input type="text" id="lib-product" value="L700" maxlength="16">
</div>
<div class="form-group">
<label for="lib-serial">Serial Number</label>
<input type="text" id="lib-serial" value="XYZZY_A" maxlength="10">
</div>
<div class="form-group">
<label for="lib-naa">NAA</label>
<input type="text" id="lib-naa" value="10:22:33:44:ab:cd:ef:00" pattern="[0-9a-f:]+">
</div>
<div class="form-group">
<label for="lib-home">Home Directory</label>
<input type="text" id="lib-home" value="/opt/mhvtl">
</div>
<div class="form-group">
<label for="lib-backoff">Backoff (ms)</label>
<input type="number" id="lib-backoff" value="400" min="0" max="10000">
</div>
</div>
</div>
</div>
</section>
<section id="drives" class="section">
<div class="section-header">
<h2>💾 Drive Configuration</h2>
<p>Manage virtual tape drives</p>
</div>
<div class="drives-container" id="drives-container">
</div>
<button class="btn btn-primary" onclick="addDrive()">
<span></span> Add Drive
</button>
</section>
<section id="tapes" class="section">
<div class="section-header">
<h2>📼 Tape Configuration</h2>
<p>Configure virtual tape media</p>
</div>
<div class="card">
<div class="card-header">
<h3>Tape Generation Settings</h3>
</div>
<div class="card-body">
<div class="form-grid">
<div class="form-group">
<label for="tape-library">Library ID</label>
<input type="number" id="tape-library" value="10" min="0" max="99">
</div>
<div class="form-group">
<label for="tape-barcode-prefix">Barcode Prefix</label>
<input type="text" id="tape-barcode-prefix" value="CLN" maxlength="6">
</div>
<div class="form-group">
<label for="tape-start-num">Starting Number</label>
<input type="number" id="tape-start-num" value="100" min="1" max="9999">
</div>
<div class="form-group">
<label for="tape-size">Tape Size (MB)</label>
<input type="number" id="tape-size" value="2500000" min="1000" max="10000000">
</div>
<div class="form-group">
<label for="tape-media-type">Media Type</label>
<select id="tape-media-type">
<option value="data">Data</option>
<option value="clean">Cleaning</option>
<option value="WORM">WORM</option>
</select>
</div>
<div class="form-group">
<label for="tape-density">Density</label>
<select id="tape-density">
<option value="LTO5">LTO-5</option>
<option value="LTO6" selected>LTO-6</option>
<option value="LTO7">LTO-7</option>
<option value="LTO8">LTO-8</option>
<option value="LTO9">LTO-9</option>
</select>
</div>
<div class="form-group">
<label for="tape-count">Number of Tapes</label>
<input type="number" id="tape-count" value="20" min="1" max="1000">
</div>
</div>
<div class="alert alert-info">
<strong> Info:</strong> Generate mktape commands for creating virtual tapes. Run these commands on the server after installation.
</div>
</div>
</div>
</section>
<section id="manage-tapes" class="section">
<div class="section-header">
<h2>🗂️ Manage Virtual Tapes</h2>
<p>Complete CRUD management for virtual tape files</p>
</div>
<div class="card">
<div class="card-header">
<h3> Create New Tapes</h3>
</div>
<div class="card-body">
<div class="form-grid">
<div class="form-group">
<label for="create-library">Library Number</label>
<input type="number" id="create-library" value="10" min="1">
</div>
<div class="form-group">
<label for="create-barcode-prefix">Barcode Prefix</label>
<input type="text" id="create-barcode-prefix" value="CLN" maxlength="6">
</div>
<div class="form-group">
<label for="create-start-num">Starting Number</label>
<input type="number" id="create-start-num" value="100" min="0">
</div>
<div class="form-group">
<label for="create-count">Number of Tapes</label>
<input type="number" id="create-count" value="1" min="1" max="100">
</div>
<div class="form-group">
<label for="create-size">Tape Size (MB)</label>
<input type="number" id="create-size" value="2500000" min="1000">
<small>Default: 2.5TB = 2,500,000 MB</small>
</div>
<div class="form-group">
<label for="create-media-type">Media Type</label>
<select id="create-media-type">
<option value="data">Data</option>
<option value="clean">Cleaning</option>
<option value="WORM">WORM</option>
</select>
</div>
<div class="form-group">
<label for="create-density">Density</label>
<select id="create-density">
<option value="LTO5">LTO-5</option>
<option value="LTO6" selected>LTO-6</option>
<option value="LTO7">LTO-7</option>
<option value="LTO8">LTO-8</option>
<option value="LTO9">LTO-9</option>
</select>
</div>
</div>
<div style="margin-top: 1rem;">
<button class="btn btn-success" onclick="createTapes()">
<span></span> Create Tapes
</button>
<div id="create-result" class="alert" style="display: none; margin-top: 1rem;"></div>
</div>
</div>
</div>
<div class="card" style="margin-top: 1rem;">
<div class="card-header">
<h3>📋 Tape Files</h3>
<button class="btn btn-primary" onclick="loadTapeList()">
<span>🔄</span> Refresh List
</button>
</div>
<div class="card-body">
<div id="tape-list-loading" style="display: none; text-align: center; padding: 2rem;">
<strong></strong> Loading tape files...
</div>
<div id="tape-list-error" class="alert alert-danger" style="display: none;"></div>
<div id="tape-list-empty" class="alert alert-info" style="display: none;">
<strong></strong> No tape files found. Create tapes using the commands from the "Tapes" section.
</div>
<div id="tape-list-container" style="display: none;">
<div style="margin-bottom: 1rem;">
<input type="text" id="tape-search" placeholder="🔍 Search tapes..."
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;"
onkeyup="filterTapes()">
</div>
<table class="tape-table" id="tape-table">
<thead>
<tr>
<th>Barcode</th>
<th>Size</th>
<th>Modified</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="tape-list-body">
</tbody>
</table>
<div style="margin-top: 1rem; padding: 1rem; background: #f8f9fa; border-radius: 4px;">
<strong>Total Tapes:</strong> <span id="tape-count-display">0</span>
</div>
</div>
</div>
</div>
<div class="card" style="margin-top: 1rem;">
<div class="card-header">
<h3>Bulk Actions</h3>
</div>
<div class="card-body">
<p>Delete multiple tapes at once. Use with caution!</p>
<div class="form-group">
<label for="bulk-delete-pattern">Delete Pattern (e.g., CLN*)</label>
<input type="text" id="bulk-delete-pattern" placeholder="CLN*">
</div>
<button class="btn btn-danger" onclick="bulkDeleteTapes()">
<span>🗑️</span> Bulk Delete
</button>
<div id="bulk-delete-result" class="alert" style="display: none; margin-top: 1rem;"></div>
</div>
</div>
</section>
<section id="iscsi" class="section">
<div class="section-header">
<h2>🔌 iSCSI Target Management</h2>
<p>Manage iSCSI targets, initiators, and LUNs</p>
</div>
<div class="card">
<div class="card-header">
<h3>🎯 iSCSI Targets</h3>
<button class="btn btn-primary" onclick="loadTargets()">
<span>🔄</span> Refresh
</button>
</div>
<div class="card-body">
<div id="target-list-empty" class="alert alert-info">
<strong></strong> No targets configured. Create a target below.
</div>
<div id="target-list-container" style="display: none;">
<table class="tape-table" id="target-table">
<thead>
<tr>
<th>TID</th>
<th>Target Name (IQN)</th>
<th>LUNs</th>
<th>ACLs</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="target-list-body">
</tbody>
</table>
</div>
</div>
</div>
<div class="card" style="margin-top: 1rem;">
<div class="card-header">
<h3> Create New Target</h3>
</div>
<div class="card-body">
<div class="form-grid">
<div class="form-group">
<label for="target-tid">Target ID (TID)</label>
<input type="number" id="target-tid" value="1" min="1">
<small>Unique target identifier</small>
</div>
<div class="form-group">
<label for="target-name">Target Name</label>
<input type="text" id="target-name" placeholder="vtl.drive0">
<small>Will be: iqn.2024-01.com.vtl-linux:<name></small>
</div>
</div>
<button class="btn btn-success" onclick="createTarget()">
<span></span> Create Target
</button>
<div id="create-target-result" class="alert" style="display: none; margin-top: 1rem;"></div>
</div>
</div>
<div class="card" style="margin-top: 1rem;">
<div class="card-header">
<h3>💾 Add LUN (Backing Store)</h3>
</div>
<div class="card-body">
<div class="form-grid">
<div class="form-group">
<label for="lun-tid">Target ID</label>
<input type="number" id="lun-tid" value="1" min="1">
</div>
<div class="form-group">
<label for="lun-number">LUN Number</label>
<input type="number" id="lun-number" value="1" min="0">
</div>
<div class="form-group">
<label for="lun-device">Device Path</label>
<input type="text" id="lun-device" placeholder="/dev/sg1">
<small>SCSI generic device (e.g., /dev/sg1, /dev/sg2)</small>
</div>
</div>
<button class="btn btn-success" onclick="addLun()">
<span></span> Add LUN
</button>
<div id="add-lun-result" class="alert" style="display: none; margin-top: 1rem;"></div>
</div>
</div>
<div class="card" style="margin-top: 1rem;">
<div class="card-header">
<h3>🔐 Manage Initiator ACLs</h3>
</div>
<div class="card-body">
<div class="form-grid">
<div class="form-group">
<label for="acl-tid">Target ID</label>
<input type="number" id="acl-tid" value="1" min="1">
</div>
<div class="form-group">
<label for="acl-address">Initiator Address</label>
<input type="text" id="acl-address" placeholder="192.168.1.100 or ALL">
<small>IP address or "ALL" for any initiator</small>
</div>
</div>
<div style="margin-top: 1rem;">
<button class="btn btn-success" onclick="bindInitiator()">
<span></span> Allow Initiator
</button>
<button class="btn btn-danger" onclick="unbindInitiator()">
<span>🚫</span> Block Initiator
</button>
</div>
<div id="acl-result" class="alert" style="display: none; margin-top: 1rem;"></div>
</div>
</div>
</section>
<section id="export" class="section">
<div class="section-header">
<h2>📤 Export Configuration</h2>
<p>Generate and download configuration files</p>
</div>
<div class="card">
<div class="card-header">
<h3>Configuration Preview</h3>
</div>
<div class="card-body">
<pre id="config-preview" class="config-preview"></pre>
</div>
</div>
<div class="button-group">
<button class="btn btn-primary" onclick="generateConfig()">
<span>🔄</span> Generate Config
</button>
<button class="btn btn-success" onclick="applyConfig()">
<span>💾</span> Apply to Server
</button>
<button class="btn btn-success" onclick="downloadConfig()">
<span>⬇️</span> Download device.conf
</button>
<button class="btn btn-secondary" onclick="copyConfig()">
<span>📋</span> Copy to Clipboard
</button>
</div>
<div id="apply-result" class="alert" style="display: none; margin-top: 1rem;"></div>
<div class="card" style="margin-top: 1rem;">
<div class="card-header">
<h3>Service Management</h3>
</div>
<div class="card-body">
<p>After applying configuration, restart the mhvtl service to apply changes.</p>
<button class="btn btn-warning" onclick="restartService()">
<span>🔄</span> Restart mhvtl Service
</button>
<div id="restart-result" class="alert" style="display: none; margin-top: 1rem;"></div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>Installation Command</h3>
</div>
<div class="card-body">
<pre id="install-command" class="config-preview"></pre>
<button class="btn btn-secondary" onclick="copyInstallCommand()">
<span>📋</span> Copy Command
</button>
</div>
</div>
</section>
</main>
<footer class="footer">
<div class="container">
<p>© Adastra Visi Teknologi • <a href="http://adastra.id">adastra.id</a> • mhvtl Configuration Manager</p>
</div>
</footer>
<script src="script.js"></script>
</body>
</html>

914
web-ui/script.js Normal file
View File

@@ -0,0 +1,914 @@
let drives = [];
let driveCounter = 0;
const driveTypes = {
'IBM ULT3580-TD5': { vendor: 'IBM', product: 'ULT3580-TD5', type: 'LTO-5' },
'IBM ULT3580-TD6': { vendor: 'IBM', product: 'ULT3580-TD6', type: 'LTO-6' },
'IBM ULT3580-TD7': { vendor: 'IBM', product: 'ULT3580-TD7', type: 'LTO-7' },
'IBM ULT3580-TD8': { vendor: 'IBM', product: 'ULT3580-TD8', type: 'LTO-8' },
'IBM ULT3580-TD9': { vendor: 'IBM', product: 'ULT3580-TD9', type: 'LTO-9' },
'HP Ultrium 5-SCSI': { vendor: 'HP', product: 'Ultrium 5-SCSI', type: 'LTO-5' },
'HP Ultrium 6-SCSI': { vendor: 'HP', product: 'Ultrium 6-SCSI', type: 'LTO-6' },
};
document.addEventListener('DOMContentLoaded', function() {
initNavigation();
addDefaultDrives();
generateConfig();
});
function initNavigation() {
const navLinks = document.querySelectorAll('.nav-link');
navLinks.forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
const targetId = this.getAttribute('href').substring(1);
document.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active'));
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
this.classList.add('active');
document.getElementById(targetId).classList.add('active');
});
});
}
function addDefaultDrives() {
for (let i = 0; i < 4; i++) {
addDrive(i < 2 ? 'IBM ULT3580-TD5' : 'IBM ULT3580-TD6');
}
}
function addDrive(driveType = 'IBM ULT3580-TD5') {
const driveId = driveCounter++;
const drive = {
id: driveId,
driveNum: drives.length,
channel: 0,
target: drives.length + 1,
lun: 0,
libraryId: 10,
slot: drives.length + 1,
type: driveType,
serial: `XYZZY_A${drives.length + 1}`,
naa: `10:22:33:44:ab:cd:ef:0${drives.length + 1}`,
compression: 3,
compressionEnabled: 1,
compressionType: 'lzo',
backoff: 400
};
drives.push(drive);
renderDrive(drive);
}
function renderDrive(drive) {
const container = document.getElementById('drives-container');
const driveInfo = driveTypes[drive.type];
const driveCard = document.createElement('div');
driveCard.className = 'drive-card';
driveCard.id = `drive-${drive.id}`;
driveCard.innerHTML = `
<div class="drive-card-header">
<h4>💾 Drive ${drive.driveNum}</h4>
<button class="btn btn-danger" onclick="removeDrive(${drive.id})">
<span>🗑️</span> Remove
</button>
</div>
<div class="form-grid">
<div class="form-group">
<label>Drive Type</label>
<select onchange="updateDriveType(${drive.id}, this.value)">
${Object.keys(driveTypes).map(type =>
`<option value="${type}" ${type === drive.type ? 'selected' : ''}>${type}</option>`
).join('')}
</select>
</div>
<div class="form-group">
<label>Drive Number</label>
<input type="number" value="${drive.driveNum}" min="0" max="99"
onchange="updateDrive(${drive.id}, 'driveNum', parseInt(this.value))">
</div>
<div class="form-group">
<label>Channel</label>
<input type="number" value="${drive.channel}" min="0" max="15"
onchange="updateDrive(${drive.id}, 'channel', parseInt(this.value))">
</div>
<div class="form-group">
<label>Target</label>
<input type="number" value="${drive.target}" min="0" max="15"
onchange="updateDrive(${drive.id}, 'target', parseInt(this.value))">
</div>
<div class="form-group">
<label>LUN</label>
<input type="number" value="${drive.lun}" min="0" max="7"
onchange="updateDrive(${drive.id}, 'lun', parseInt(this.value))">
</div>
<div class="form-group">
<label>Library ID</label>
<input type="number" value="${drive.libraryId}" min="0" max="99"
onchange="updateDrive(${drive.id}, 'libraryId', parseInt(this.value))">
</div>
<div class="form-group">
<label>Slot</label>
<input type="number" value="${drive.slot}" min="1" max="999"
onchange="updateDrive(${drive.id}, 'slot', parseInt(this.value))">
</div>
<div class="form-group">
<label>Serial Number</label>
<input type="text" value="${drive.serial}" maxlength="10"
onchange="updateDrive(${drive.id}, 'serial', this.value)">
</div>
<div class="form-group">
<label>NAA</label>
<input type="text" value="${drive.naa}" pattern="[0-9a-f:]+"
onchange="updateDrive(${drive.id}, 'naa', this.value)">
</div>
<div class="form-group">
<label>Compression Factor</label>
<input type="number" value="${drive.compression}" min="1" max="10"
onchange="updateDrive(${drive.id}, 'compression', parseInt(this.value))">
</div>
<div class="form-group">
<label>Compression Type</label>
<select onchange="updateDrive(${drive.id}, 'compressionType', this.value)">
<option value="lzo" ${drive.compressionType === 'lzo' ? 'selected' : ''}>LZO</option>
<option value="zlib" ${drive.compressionType === 'zlib' ? 'selected' : ''}>ZLIB</option>
</select>
</div>
<div class="form-group">
<label>Backoff (ms)</label>
<input type="number" value="${drive.backoff}" min="0" max="10000"
onchange="updateDrive(${drive.id}, 'backoff', parseInt(this.value))">
</div>
</div>
`;
container.appendChild(driveCard);
}
function updateDrive(driveId, field, value) {
const drive = drives.find(d => d.id === driveId);
if (drive) {
drive[field] = value;
}
}
function updateDriveType(driveId, type) {
const drive = drives.find(d => d.id === driveId);
if (drive) {
drive.type = type;
}
}
function removeDrive(driveId) {
const index = drives.findIndex(d => d.id === driveId);
if (index !== -1) {
drives.splice(index, 1);
document.getElementById(`drive-${driveId}`).remove();
drives.forEach((drive, idx) => {
drive.driveNum = idx;
});
document.getElementById('drives-container').innerHTML = '';
drives.forEach(drive => renderDrive(drive));
}
}
function generateConfig() {
const libId = document.getElementById('lib-id').value;
const libChannel = document.getElementById('lib-channel').value;
const libTarget = document.getElementById('lib-target').value;
const libLun = document.getElementById('lib-lun').value;
const libVendor = document.getElementById('lib-vendor').value;
const libProduct = document.getElementById('lib-product').value;
const libSerial = document.getElementById('lib-serial').value;
const libNaa = document.getElementById('lib-naa').value;
const libHome = document.getElementById('lib-home').value;
const libBackoff = document.getElementById('lib-backoff').value;
let config = `VERSION: 5\n\n`;
config += `Library: ${libId} CHANNEL: ${libChannel.padStart(2, '0')} TARGET: ${libTarget.padStart(2, '0')} LUN: ${libLun.padStart(2, '0')}\n`;
config += ` Vendor identification: ${libVendor}\n`;
config += ` Product identification: ${libProduct}\n`;
config += ` Unit serial number: ${libSerial}\n`;
config += ` NAA: ${libNaa}\n`;
config += ` Home directory: ${libHome}\n`;
config += ` Backoff: ${libBackoff}\n`;
drives.forEach(drive => {
const driveInfo = driveTypes[drive.type];
config += `\nDrive: ${drive.driveNum.toString().padStart(2, '0')} CHANNEL: ${drive.channel.toString().padStart(2, '0')} TARGET: ${drive.target.toString().padStart(2, '0')} LUN: ${drive.lun.toString().padStart(2, '0')}\n`;
config += ` Library ID: ${drive.libraryId} Slot: ${drive.slot.toString().padStart(2, '0')}\n`;
config += ` Vendor identification: ${driveInfo.vendor}\n`;
config += ` Product identification: ${driveInfo.product}\n`;
config += ` Unit serial number: ${drive.serial}\n`;
config += ` NAA: ${drive.naa}\n`;
config += ` Compression: factor ${drive.compression} enabled ${drive.compressionEnabled}\n`;
config += ` Compression type: ${drive.compressionType}\n`;
config += ` Backoff: ${drive.backoff}\n`;
});
document.getElementById('config-preview').textContent = config;
const tapeLibrary = document.getElementById('tape-library').value;
const tapeBarcodePrefix = document.getElementById('tape-barcode-prefix').value;
const tapeStartNum = parseInt(document.getElementById('tape-start-num').value);
const tapeSize = document.getElementById('tape-size').value;
const tapeMediaType = document.getElementById('tape-media-type').value;
const tapeDensity = document.getElementById('tape-density').value;
const tapeCount = parseInt(document.getElementById('tape-count').value);
let installCmds = '#!/bin/bash\n';
installCmds += '# Generate virtual tapes for mhvtl\n';
installCmds += '# Run this script after mhvtl installation\n\n';
for (let i = 0; i < tapeCount; i++) {
const barcodeNum = String(tapeStartNum + i).padStart(6, '0');
const barcode = `${tapeBarcodePrefix}${barcodeNum}`;
installCmds += `mktape -l ${tapeLibrary} -m ${barcode} -s ${tapeSize} -t ${tapeMediaType} -d ${tapeDensity}\n`;
}
document.getElementById('install-command').textContent = installCmds;
}
function downloadConfig() {
generateConfig();
const config = document.getElementById('config-preview').textContent;
const blob = new Blob([config], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'device.conf';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showNotification('Configuration downloaded successfully!', 'success');
}
function copyConfig() {
generateConfig();
const config = document.getElementById('config-preview').textContent;
navigator.clipboard.writeText(config).then(() => {
showNotification('Configuration copied to clipboard!', 'success');
}).catch(() => {
showNotification('Failed to copy configuration', 'danger');
});
}
function copyInstallCommand() {
const cmd = document.getElementById('install-command').textContent;
navigator.clipboard.writeText(cmd).then(() => {
showNotification('Command copied to clipboard!', 'success');
}).catch(() => {
showNotification('Failed to copy command', 'danger');
});
}
function generateConfigText() {
const libId = document.getElementById('lib-id').value;
const libChannel = document.getElementById('lib-channel').value;
const libTarget = document.getElementById('lib-target').value;
const libLun = document.getElementById('lib-lun').value;
const libVendor = document.getElementById('lib-vendor').value;
const libProduct = document.getElementById('lib-product').value;
const libSerial = document.getElementById('lib-serial').value;
const libNaa = document.getElementById('lib-naa').value;
const libHome = document.getElementById('lib-home').value;
const libBackoff = document.getElementById('lib-backoff').value;
let config = `VERSION: 5\n\n`;
config += `Library: ${libId} CHANNEL: ${libChannel.padStart(2, '0')} TARGET: ${libTarget.padStart(2, '0')} LUN: ${libLun.padStart(2, '0')}\n`;
config += ` Vendor identification: ${libVendor}\n`;
config += ` Product identification: ${libProduct}\n`;
config += ` Unit serial number: ${libSerial}\n`;
config += ` NAA: ${libNaa}\n`;
config += ` Home directory: ${libHome}\n`;
config += ` Backoff: ${libBackoff}\n`;
drives.forEach(drive => {
const driveInfo = driveTypes[drive.type];
config += `\nDrive: ${drive.driveNum.toString().padStart(2, '0')} CHANNEL: ${drive.channel.toString().padStart(2, '0')} TARGET: ${drive.target.toString().padStart(2, '0')} LUN: ${drive.lun.toString().padStart(2, '0')}\n`;
config += ` Library ID: ${drive.libraryId} Slot: ${drive.slot.toString().padStart(2, '0')}\n`;
config += ` Vendor identification: ${driveInfo.vendor}\n`;
config += ` Product identification: ${driveInfo.product}\n`;
config += ` Unit serial number: ${drive.serial}\n`;
config += ` NAA: ${drive.naa}\n`;
config += ` Compression: factor ${drive.compression} enabled ${drive.compressionEnabled}\n`;
config += ` Compression type: ${drive.compressionType}\n`;
config += ` Backoff: ${drive.backoff}\n`;
});
return config;
}
function applyConfig() {
const config = generateConfigText();
const resultDiv = document.getElementById('apply-result');
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-info';
resultDiv.innerHTML = '<strong>⏳</strong> Applying configuration to server...';
fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'save_config',
config: config
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
resultDiv.className = 'alert alert-success';
resultDiv.innerHTML = `
<strong>✅ Success!</strong> Configuration saved to ${data.file}<br>
<small>Restart mhvtl service to apply changes using the button below.</small>
`;
showNotification('Configuration applied successfully!', 'success');
} else {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${data.error}`;
showNotification('Failed to apply configuration', 'danger');
}
})
.catch(error => {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${error.message}`;
showNotification('Failed to apply configuration', 'danger');
});
}
function restartService() {
const resultDiv = document.getElementById('restart-result');
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-info';
resultDiv.innerHTML = '<strong>⏳</strong> Restarting mhvtl service...';
fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'restart_service'
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
resultDiv.className = 'alert alert-success';
resultDiv.innerHTML = '<strong>✅ Success!</strong> Service restarted successfully';
showNotification('Service restarted successfully!', 'success');
} else {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${data.error}`;
showNotification('Failed to restart service', 'danger');
}
})
.catch(error => {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${error.message}`;
showNotification('Failed to restart service', 'danger');
});
}
function showNotification(message, type) {
const notification = document.createElement('div');
notification.className = `alert alert-${type}`;
notification.style.position = 'fixed';
notification.style.top = '20px';
notification.style.right = '20px';
notification.style.zIndex = '9999';
notification.style.minWidth = '300px';
notification.style.animation = 'slideIn 0.3s ease';
notification.innerHTML = `<strong>${type === 'success' ? '✅' : '❌'}</strong> ${message}`;
document.body.appendChild(notification);
setTimeout(() => {
notification.style.animation = 'slideOut 0.3s ease';
setTimeout(() => {
document.body.removeChild(notification);
}, 300);
}, 3000);
}
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(400px);
opacity: 0;
}
}
`;
document.head.appendChild(style);
let tapeListCache = [];
function loadTapeList() {
const loadingDiv = document.getElementById('tape-list-loading');
const errorDiv = document.getElementById('tape-list-error');
const emptyDiv = document.getElementById('tape-list-empty');
const containerDiv = document.getElementById('tape-list-container');
loadingDiv.style.display = 'block';
errorDiv.style.display = 'none';
emptyDiv.style.display = 'none';
containerDiv.style.display = 'none';
fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'list_tapes'
})
})
.then(response => response.json())
.then(data => {
loadingDiv.style.display = 'none';
if (data.success) {
tapeListCache = data.tapes;
if (data.tapes.length === 0) {
emptyDiv.style.display = 'block';
} else {
containerDiv.style.display = 'block';
renderTapeList(data.tapes);
}
} else {
errorDiv.style.display = 'block';
errorDiv.innerHTML = `<strong>❌ Error:</strong> ${data.error}`;
}
})
.catch(error => {
loadingDiv.style.display = 'none';
errorDiv.style.display = 'block';
errorDiv.innerHTML = `<strong>❌ Error:</strong> ${error.message}`;
});
}
function renderTapeList(tapes) {
const tbody = document.getElementById('tape-list-body');
const countDisplay = document.getElementById('tape-count-display');
tbody.innerHTML = '';
countDisplay.textContent = tapes.length;
tapes.forEach(tape => {
const row = document.createElement('tr');
row.innerHTML = `
<td class="tape-barcode">${tape.name}</td>
<td class="tape-size">${tape.size}</td>
<td class="tape-date">${tape.modified}</td>
<td class="tape-actions">
<button class="btn btn-danger btn-small" onclick="deleteTape('${tape.name}')">
<span>🗑️</span> Delete
</button>
</td>
`;
tbody.appendChild(row);
});
}
function filterTapes() {
const searchTerm = document.getElementById('tape-search').value.toLowerCase();
const filteredTapes = tapeListCache.filter(tape =>
tape.name.toLowerCase().includes(searchTerm)
);
renderTapeList(filteredTapes);
}
function deleteTape(tapeName) {
if (!confirm(`Are you sure you want to delete tape "${tapeName}"?\n\nThis action cannot be undone!`)) {
return;
}
fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'delete_tape',
tape_name: tapeName
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification(`Tape "${tapeName}" deleted successfully!`, 'success');
loadTapeList();
} else {
showNotification(`Failed to delete tape: ${data.error}`, 'danger');
}
})
.catch(error => {
showNotification(`Error: ${error.message}`, 'danger');
});
}
function bulkDeleteTapes() {
const pattern = document.getElementById('bulk-delete-pattern').value.trim();
const resultDiv = document.getElementById('bulk-delete-result');
if (!pattern) {
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = '<strong>❌ Error:</strong> Please enter a delete pattern';
return;
}
if (!confirm(`Are you sure you want to delete all tapes matching "${pattern}"?\n\nThis action cannot be undone!`)) {
return;
}
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-info';
resultDiv.innerHTML = '<strong>⏳</strong> Deleting tapes...';
fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'bulk_delete_tapes',
pattern: pattern
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
resultDiv.className = 'alert alert-success';
resultDiv.innerHTML = `<strong>✅ Success!</strong> Deleted ${data.deleted_count} tape(s)`;
showNotification(`Deleted ${data.deleted_count} tape(s)`, 'success');
loadTapeList();
} else {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${data.error}`;
}
})
.catch(error => {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${error.message}`;
});
}
function createTapes() {
const library = document.getElementById('create-library').value;
const barcodePrefix = document.getElementById('create-barcode-prefix').value.trim();
const startNum = parseInt(document.getElementById('create-start-num').value);
const count = parseInt(document.getElementById('create-count').value);
const size = document.getElementById('create-size').value;
const mediaType = document.getElementById('create-media-type').value;
const density = document.getElementById('create-density').value;
const resultDiv = document.getElementById('create-result');
if (!barcodePrefix) {
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = '<strong>❌ Error:</strong> Barcode prefix is required';
return;
}
if (count < 1 || count > 100) {
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = '<strong>❌ Error:</strong> Number of tapes must be between 1 and 100';
return;
}
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-info';
resultDiv.innerHTML = '<strong>⏳</strong> Creating tapes...';
fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'create_tapes',
library: library,
barcode_prefix: barcodePrefix,
start_num: startNum,
count: count,
size: size,
media_type: mediaType,
density: density
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
resultDiv.className = 'alert alert-success';
resultDiv.innerHTML = `<strong>✅ Success!</strong> Created ${data.created_count} tape(s)`;
if (data.errors && data.errors.length > 0) {
resultDiv.innerHTML += `<br><small>Errors: ${data.errors.join(', ')}</small>`;
}
showNotification(`Created ${data.created_count} tape(s)`, 'success');
loadTapeList();
} else {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${data.error}`;
}
})
.catch(error => {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${error.message}`;
});
}
// ============================================
// iSCSI Target Management Functions
// ============================================
function loadTargets() {
fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'list_targets'
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
const tbody = document.getElementById('target-list-body');
const emptyDiv = document.getElementById('target-list-empty');
const containerDiv = document.getElementById('target-list-container');
if (data.targets.length === 0) {
emptyDiv.style.display = 'block';
containerDiv.style.display = 'none';
} else {
emptyDiv.style.display = 'none';
containerDiv.style.display = 'block';
tbody.innerHTML = data.targets.map(target => `
<tr>
<td><strong>${target.tid}</strong></td>
<td><code>${target.name}</code></td>
<td>${target.luns || 0}</td>
<td>${target.acls || 0}</td>
<td>
<button class="btn btn-danger btn-sm" onclick="deleteTarget(${target.tid})">
🗑️ Delete
</button>
</td>
</tr>
`).join('');
}
} else {
showNotification(data.error, 'error');
}
})
.catch(error => {
showNotification('Failed to load targets: ' + error.message, 'error');
});
}
function createTarget() {
const tid = document.getElementById('target-tid').value;
const name = document.getElementById('target-name').value.trim();
const resultDiv = document.getElementById('create-target-result');
if (!name) {
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = '<strong>❌ Error:</strong> Target name is required';
return;
}
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-info';
resultDiv.innerHTML = '<strong>⏳</strong> Creating target...';
fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'create_target',
tid: tid,
name: name
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
resultDiv.className = 'alert alert-success';
resultDiv.innerHTML = `<strong>✅ Success!</strong> Target created: ${data.iqn}`;
showNotification('Target created successfully', 'success');
loadTargets();
} else {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${data.error}`;
}
})
.catch(error => {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${error.message}`;
});
}
function deleteTarget(tid) {
if (!confirm(`Delete target ${tid}? This will remove all LUNs and ACLs.`)) {
return;
}
fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'delete_target',
tid: tid
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification('Target deleted successfully', 'success');
loadTargets();
} else {
showNotification(data.error, 'error');
}
})
.catch(error => {
showNotification('Failed to delete target: ' + error.message, 'error');
});
}
function addLun() {
const tid = document.getElementById('lun-tid').value;
const lun = document.getElementById('lun-number').value;
const device = document.getElementById('lun-device').value.trim();
const resultDiv = document.getElementById('add-lun-result');
if (!device) {
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = '<strong>❌ Error:</strong> Device path is required';
return;
}
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-info';
resultDiv.innerHTML = '<strong>⏳</strong> Adding LUN...';
fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'add_lun',
tid: tid,
lun: lun,
device: device
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
resultDiv.className = 'alert alert-success';
resultDiv.innerHTML = '<strong>✅ Success!</strong> LUN added successfully';
showNotification('LUN added successfully', 'success');
loadTargets();
} else {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${data.error}`;
}
})
.catch(error => {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${error.message}`;
});
}
function bindInitiator() {
const tid = document.getElementById('acl-tid').value;
const address = document.getElementById('acl-address').value.trim();
const resultDiv = document.getElementById('acl-result');
if (!address) {
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = '<strong>❌ Error:</strong> Initiator address is required';
return;
}
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-info';
resultDiv.innerHTML = '<strong>⏳</strong> Binding initiator...';
fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'bind_initiator',
tid: tid,
address: address
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
resultDiv.className = 'alert alert-success';
resultDiv.innerHTML = '<strong>✅ Success!</strong> Initiator allowed';
showNotification('Initiator allowed successfully', 'success');
loadTargets();
} else {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${data.error}`;
}
})
.catch(error => {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${error.message}`;
});
}
function unbindInitiator() {
const tid = document.getElementById('acl-tid').value;
const address = document.getElementById('acl-address').value.trim();
const resultDiv = document.getElementById('acl-result');
if (!address) {
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = '<strong>❌ Error:</strong> Initiator address is required';
return;
}
if (!confirm(`Block initiator ${address} from target ${tid}?`)) {
return;
}
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-info';
resultDiv.innerHTML = '<strong>⏳</strong> Unbinding initiator...';
fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'unbind_initiator',
tid: tid,
address: address
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
resultDiv.className = 'alert alert-success';
resultDiv.innerHTML = '<strong>✅ Success!</strong> Initiator blocked';
showNotification('Initiator blocked successfully', 'success');
loadTargets();
} else {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${data.error}`;
}
})
.catch(error => {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${error.message}`;
});
}

442
web-ui/style.css Normal file
View File

@@ -0,0 +1,442 @@
:root {
--primary-color: #2563eb;
--secondary-color: #64748b;
--success-color: #10b981;
--danger-color: #ef4444;
--warning-color: #f59e0b;
--info-color: #3b82f6;
--dark-bg: #0f172a;
--card-bg: #1e293b;
--border-color: #334155;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--hover-bg: #334155;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
color: var(--text-primary);
line-height: 1.6;
min-height: 100vh;
}
.navbar {
background: rgba(15, 23, 42, 0.95);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--border-color);
padding: 1rem 0;
position: sticky;
top: 0;
z-index: 1000;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.navbar .container {
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-brand h1 {
font-size: 1.5rem;
color: var(--primary-color);
margin-bottom: 0.25rem;
}
.nav-brand .subtitle {
font-size: 0.875rem;
color: var(--text-secondary);
}
.nav-links {
display: flex;
gap: 1rem;
}
.nav-link {
color: var(--text-secondary);
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
transition: all 0.3s ease;
font-weight: 500;
}
.nav-link:hover {
color: var(--text-primary);
background: var(--hover-bg);
}
.nav-link.active {
color: var(--primary-color);
background: rgba(37, 99, 235, 0.1);
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1.5rem;
}
main.container {
padding: 2rem 1.5rem;
}
.section {
display: none;
animation: fadeIn 0.3s ease;
}
.section.active {
display: block;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.section-header {
margin-bottom: 2rem;
}
.section-header h2 {
font-size: 2rem;
margin-bottom: 0.5rem;
color: var(--text-primary);
}
.section-header p {
color: var(--text-secondary);
font-size: 1.125rem;
}
.card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 0.75rem;
margin-bottom: 1.5rem;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 12px rgba(0, 0, 0, 0.2);
}
.card-header {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border-color);
background: rgba(30, 41, 59, 0.5);
}
.card-header h3 {
font-size: 1.25rem;
color: var(--text-primary);
}
.card-body {
padding: 1.5rem;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.25rem;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group label {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.form-group input,
.form-group select {
padding: 0.75rem;
background: var(--dark-bg);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
color: var(--text-primary);
font-size: 1rem;
transition: all 0.3s ease;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.form-group input:hover,
.form-group select:hover {
border-color: var(--primary-color);
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.btn:active {
transform: translateY(0);
}
.btn-primary {
background: var(--primary-color);
color: white;
}
.btn-primary:hover {
background: #1d4ed8;
}
.btn-success {
background: var(--success-color);
color: white;
}
.btn-success:hover {
background: #059669;
}
.btn-danger {
background: var(--danger-color);
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-secondary {
background: var(--secondary-color);
color: white;
}
.btn-secondary:hover {
background: #475569;
}
.button-group {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1.5rem;
}
.drives-container {
display: grid;
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.drive-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 0.75rem;
padding: 1.5rem;
position: relative;
}
.drive-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.drive-card-header h4 {
color: var(--primary-color);
font-size: 1.125rem;
}
.config-preview {
background: var(--dark-bg);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
padding: 1.5rem;
color: var(--text-primary);
font-family: 'Courier New', monospace;
font-size: 0.875rem;
overflow-x: auto;
white-space: pre-wrap;
line-height: 1.5;
}
.alert {
padding: 1rem 1.25rem;
border-radius: 0.5rem;
margin-top: 1rem;
border-left: 4px solid;
}
.alert-info {
background: rgba(59, 130, 246, 0.1);
border-color: var(--info-color);
color: var(--text-primary);
}
.alert-success {
background: rgba(16, 185, 129, 0.1);
border-color: var(--success-color);
color: var(--text-primary);
}
.alert-warning {
background: rgba(245, 158, 11, 0.1);
border-color: var(--warning-color);
color: var(--text-primary);
}
.alert-danger {
background: rgba(239, 68, 68, 0.1);
border-color: var(--danger-color);
color: var(--text-primary);
}
.footer {
background: rgba(15, 23, 42, 0.95);
border-top: 1px solid var(--border-color);
padding: 2rem 0;
margin-top: 4rem;
text-align: center;
}
.footer p {
color: var(--text-secondary);
font-size: 0.875rem;
}
.footer a {
color: var(--primary-color);
text-decoration: none;
transition: color 0.3s ease;
}
.footer a:hover {
color: #1d4ed8;
}
.tape-table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.tape-table th,
.tape-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.tape-table th {
background: rgba(37, 99, 235, 0.1);
color: var(--primary-color);
font-weight: 600;
text-transform: uppercase;
font-size: 0.875rem;
letter-spacing: 0.05em;
}
.tape-table tbody tr {
transition: background-color 0.2s;
}
.tape-table tbody tr:hover {
background: var(--hover-bg);
}
.tape-table .tape-barcode {
font-family: 'Courier New', monospace;
font-weight: 600;
color: var(--info-color);
}
.tape-table .tape-size {
color: var(--text-secondary);
}
.tape-table .tape-date {
color: var(--text-secondary);
font-size: 0.875rem;
}
.tape-actions {
display: flex;
gap: 0.5rem;
}
.btn-small {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
}
@media (max-width: 768px) {
.navbar .container {
flex-direction: column;
gap: 1rem;
}
.nav-links {
width: 100%;
justify-content: center;
flex-wrap: wrap;
}
.form-grid {
grid-template-columns: 1fr;
}
.button-group {
flex-direction: column;
}
.btn {
width: 100%;
justify-content: center;
}
.tape-table {
font-size: 0.875rem;
}
}

1
wget-log Normal file
View File

@@ -0,0 +1 @@
2025-12-08 18:14:21 URL:http://deb.debian.org/debian/dists/bookworm/InRelease [151080/151080] -> "/tmp/vtl-build/chroot/var/lib/apt/lists/partial/deb.debian.org_debian_dists_bookworm_InRelease" [1]

1
wget-log.1 Normal file
View File

@@ -0,0 +1 @@
2025-12-08 18:14:27 URL:http://deb.debian.org/debian/dists/bookworm/main/binary-amd64/by-hash/SHA256/3df8d07aeded5e65d9847edfa120fa5b216bbd8e0f7f048dfefe6905e4a12011 [8791424/8791424] -> "/tmp/vtl-build/chroot/var/lib/apt/lists/partial/deb.debian.org_debian_dists_bookworm_main_binary-amd64_Packages.xz" [1]