BAMS initial project structure

This commit is contained in:
2025-12-23 18:34:39 +00:00
parent e1df870f98
commit 861e0f65c3
24 changed files with 2495 additions and 0 deletions

39
.gitignore vendored Normal file
View File

@@ -0,0 +1,39 @@
# Binaries
bin/
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary
*.test
# Output of the go coverage tool
*.out
# Go workspace file
go.work
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Build artifacts
*.o
*.a
# Logs
*.log
# Config (local)
config.yaml
!config.yaml.example

7
BAMS.code-workspace Normal file
View File

@@ -0,0 +1,7 @@
{
"folders": [
{
"path": "."
}
]
}

23
Makefile Normal file
View File

@@ -0,0 +1,23 @@
.PHONY: build install clean test
build:
cd backend && go build -o ../bin/bams-backend .
install: build
sudo cp bin/bams-backend /usr/local/bin/
sudo cp -r cockpit /usr/share/cockpit/bams
sudo cp configs/bams.service /etc/systemd/system/
sudo cp configs/config.yaml.example /etc/bams/config.yaml
sudo cp configs/polkit.rules /etc/polkit-1/rules.d/50-bams.rules
sudo systemctl daemon-reload
clean:
rm -rf bin/
cd backend && go clean
test:
cd backend && go test ./...
run:
cd backend && go run .

132
README.md Normal file
View File

@@ -0,0 +1,132 @@
# BAMS - Backup Appliance Management System
A comprehensive management system for backup appliances, providing unified control over disk repositories, tape libraries, iSCSI targets, and Bacula integration.
## Features
- **Dashboard**: Real-time monitoring of storage, tape library, iSCSI sessions, and Bacula status
- **Disk Repository Management**: Create and manage LVM and ZFS repositories
- **Tape Library Management**: Inventory, load/unload operations for LTO-8 libraries
- **iSCSI Target Management**: Configure SCST targets with portal and initiator ACL
- **Bacula Integration**: Status monitoring, config generation, and inventory operations
- **Logs & Diagnostics**: Live log viewing and support bundle generation
## Architecture
- **Backend**: Go-based REST API service running as systemd service
- **Frontend**: Cockpit plugin providing web-based UI
- **Storage**: LVM and ZFS support for disk repositories
- **iSCSI**: SCST framework for iSCSI target management
- **Tape**: Support for LTO-8 SAS/FC tape libraries
## Requirements
- Ubuntu Server 24.04 LTS
- Go 1.21+
- Cockpit 300+
- SCST (stable)
- Bacula Community/Enterprise
- LTO-8 compatible tape library
## Installation
### 1. Build Backend
```bash
cd backend
go mod download
go build -o bams-backend .
sudo cp bams-backend /usr/local/bin/
```
### 2. Install Cockpit Plugin
```bash
sudo cp -r cockpit /usr/share/cockpit/bams
sudo systemctl restart cockpit
```
### 3. Configure System
```bash
# Create user and group
sudo useradd -r -s /bin/false bams
sudo groupadd bams-admin
sudo groupadd bams-operator
# Create directories
sudo mkdir -p /var/lib/bams
sudo mkdir -p /etc/bams
sudo mkdir -p /var/log/bams
# Copy configuration
sudo cp configs/config.yaml.example /etc/bams/config.yaml
sudo cp configs/bams.service /etc/systemd/system/
# Install polkit rules
sudo cp configs/polkit.rules /etc/polkit-1/rules.d/50-bams.rules
# Set permissions
sudo chown -R bams:bams /var/lib/bams
sudo chown -R bams:bams /var/log/bams
sudo chmod 640 /etc/bams/config.yaml
```
### 4. Start Service
```bash
sudo systemctl daemon-reload
sudo systemctl enable bams.service
sudo systemctl start bams.service
```
## Configuration
Edit `/etc/bams/config.yaml` to customize:
- Port number (default: 8080)
- Log level (debug, info, warn, error)
- Data directory
- SCST and Bacula config paths
- Security settings
## Usage
1. Access Cockpit web interface
2. Navigate to "BAMS" in the tools menu
3. Use the dashboard to monitor system status
4. Manage storage repositories, tape library, and iSCSI targets through the UI
## API Endpoints
The backend provides a REST API at `http://localhost:8080/api/v1/`:
- `GET /dashboard` - Dashboard summary
- `GET /disk/repositories` - List repositories
- `POST /disk/repositories` - Create repository
- `GET /tape/library` - Library status
- `POST /tape/inventory` - Run inventory
- `GET /iscsi/targets` - List iSCSI targets
- `POST /iscsi/targets` - Create target
- `GET /bacula/status` - Bacula SD status
- `GET /logs/{service}` - Get logs
- `GET /diagnostics/bundle` - Download support bundle
## Security
- Privileged operations require polkit authorization
- Users must be in `bams-admin` or `bams-operator` groups
- HTTPS/TLS recommended for production
- Audit logging for configuration changes
## Troubleshooting
- Check service status: `sudo systemctl status bams`
- View logs: `sudo journalctl -u bams -f`
- Verify Cockpit plugin: Check `/usr/share/cockpit/bams` exists
- Test API: `curl http://localhost:8080/api/v1/health`
## License
GPL-3.0

8
backend/go.mod Normal file
View File

@@ -0,0 +1,8 @@
module github.com/bams/backend
go 1.21
require (
github.com/gorilla/mux v1.8.1
gopkg.in/yaml.v3 v3.0.1
)

6
backend/go.sum Normal file
View File

@@ -0,0 +1,6 @@
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,422 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/bams/backend/internal/services"
"github.com/gorilla/mux"
)
func handleHealth() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusOK, map[string]interface{}{
"status": "ok",
"timestamp": time.Now().Unix(),
})
}
}
func handleDashboard(sm *services.ServiceManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Get dashboard data from all services
dashboard := map[string]interface{}{
"disk": map[string]interface{}{
"total_capacity": 0,
"used_capacity": 0,
"repositories": 0,
},
"tape": map[string]interface{}{
"library_status": "unknown",
"drives_active": 0,
"total_slots": 0,
"loaded_tapes": 0,
},
"iscsi": map[string]interface{}{
"targets": 0,
"sessions": 0,
},
"bacula": map[string]interface{}{
"status": "unknown",
},
"alerts": []map[string]interface{}{},
}
// Populate from services
repos, _ := sm.Disk.ListRepositories()
if repos != nil {
dashboard["disk"].(map[string]interface{})["repositories"] = len(repos)
}
library, _ := sm.Tape.GetLibrary()
if library != nil {
dashboard["tape"].(map[string]interface{})["library_status"] = library.Status
dashboard["tape"].(map[string]interface{})["drives_active"] = library.ActiveDrives
dashboard["tape"].(map[string]interface{})["total_slots"] = library.TotalSlots
}
targets, _ := sm.ISCSI.ListTargets()
if targets != nil {
dashboard["iscsi"].(map[string]interface{})["targets"] = len(targets)
}
sessions, _ := sm.ISCSI.ListSessions()
if sessions != nil {
dashboard["iscsi"].(map[string]interface{})["sessions"] = len(sessions)
}
baculaStatus, _ := sm.Bacula.GetStatus()
if baculaStatus != nil {
dashboard["bacula"].(map[string]interface{})["status"] = baculaStatus.Status
}
jsonResponse(w, http.StatusOK, dashboard)
}
}
// Disk repository handlers
func handleListRepositories(sm *services.ServiceManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
repos, err := sm.Disk.ListRepositories()
if err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonResponse(w, http.StatusOK, repos)
}
}
func handleCreateRepository(sm *services.ServiceManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Size string `json:"size"`
Type string `json:"type"` // "lvm" or "zfs"
VGName string `json:"vg_name,omitempty"`
PoolName string `json:"pool_name,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "Invalid request body")
return
}
repo, err := sm.Disk.CreateRepository(req.Name, req.Size, req.Type, req.VGName, req.PoolName)
if err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonResponse(w, http.StatusCreated, repo)
}
}
func handleGetRepository(sm *services.ServiceManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
repo, err := sm.Disk.GetRepository(vars["id"])
if err != nil {
jsonError(w, http.StatusNotFound, err.Error())
return
}
jsonResponse(w, http.StatusOK, repo)
}
}
func handleDeleteRepository(sm *services.ServiceManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
if err := sm.Disk.DeleteRepository(vars["id"]); err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonResponse(w, http.StatusNoContent, nil)
}
}
// Tape library handlers
func handleGetLibrary(sm *services.ServiceManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
library, err := sm.Tape.GetLibrary()
if err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonResponse(w, http.StatusOK, library)
}
}
func handleInventory(sm *services.ServiceManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := sm.Tape.RunInventory(); err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonResponse(w, http.StatusOK, map[string]string{"status": "inventory_started"})
}
}
func handleListDrives(sm *services.ServiceManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
drives, err := sm.Tape.ListDrives()
if err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonResponse(w, http.StatusOK, drives)
}
}
func handleLoadTape(sm *services.ServiceManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
var req struct {
Slot int `json:"slot"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "Invalid request body")
return
}
if err := sm.Tape.LoadTape(vars["id"], req.Slot); err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonResponse(w, http.StatusOK, map[string]string{"status": "load_started"})
}
}
func handleUnloadTape(sm *services.ServiceManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
var req struct {
Slot int `json:"slot"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "Invalid request body")
return
}
if err := sm.Tape.UnloadTape(vars["id"], req.Slot); err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonResponse(w, http.StatusOK, map[string]string{"status": "unload_started"})
}
}
func handleListSlots(sm *services.ServiceManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
slots, err := sm.Tape.ListSlots()
if err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonResponse(w, http.StatusOK, slots)
}
}
// iSCSI target handlers
func handleListTargets(sm *services.ServiceManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
targets, err := sm.ISCSI.ListTargets()
if err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonResponse(w, http.StatusOK, targets)
}
}
func handleCreateTarget(sm *services.ServiceManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
IQN string `json:"iqn"`
Portals []string `json:"portals"`
Initiators []string `json:"initiators"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "Invalid request body")
return
}
target, err := sm.ISCSI.CreateTarget(req.IQN, req.Portals, req.Initiators)
if err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonResponse(w, http.StatusCreated, target)
}
}
func handleGetTarget(sm *services.ServiceManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
target, err := sm.ISCSI.GetTarget(vars["id"])
if err != nil {
jsonError(w, http.StatusNotFound, err.Error())
return
}
jsonResponse(w, http.StatusOK, target)
}
}
func handleUpdateTarget(sm *services.ServiceManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
var req struct {
Portals []string `json:"portals"`
Initiators []string `json:"initiators"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "Invalid request body")
return
}
target, err := sm.ISCSI.UpdateTarget(vars["id"], req.Portals, req.Initiators)
if err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonResponse(w, http.StatusOK, target)
}
}
func handleDeleteTarget(sm *services.ServiceManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
if err := sm.ISCSI.DeleteTarget(vars["id"]); err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonResponse(w, http.StatusNoContent, nil)
}
}
func handleApplyTarget(sm *services.ServiceManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
if err := sm.ISCSI.ApplyTarget(vars["id"]); err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonResponse(w, http.StatusOK, map[string]string{"status": "applied"})
}
}
func handleListSessions(sm *services.ServiceManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sessions, err := sm.ISCSI.ListSessions()
if err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonResponse(w, http.StatusOK, sessions)
}
}
// Bacula handlers
func handleBaculaStatus(sm *services.ServiceManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
status, err := sm.Bacula.GetStatus()
if err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonResponse(w, http.StatusOK, status)
}
}
func handleGetBaculaConfig(sm *services.ServiceManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
config, err := sm.Bacula.GetConfig()
if err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonResponse(w, http.StatusOK, config)
}
}
func handleGenerateBaculaConfig(sm *services.ServiceManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
config, err := sm.Bacula.GenerateConfig()
if err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonResponse(w, http.StatusOK, config)
}
}
func handleBaculaInventory(sm *services.ServiceManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := sm.Bacula.RunInventory(); err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonResponse(w, http.StatusOK, map[string]string{"status": "inventory_started"})
}
}
func handleBaculaRestart(sm *services.ServiceManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := sm.Bacula.Restart(); err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonResponse(w, http.StatusOK, map[string]string{"status": "restart_started"})
}
}
// Logs and diagnostics handlers
func handleGetLogs(sm *services.ServiceManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
lines := 100
if linesStr := r.URL.Query().Get("lines"); linesStr != "" {
if n, err := strconv.Atoi(linesStr); err == nil {
lines = n
}
}
logs, err := sm.Logs.GetLogs(vars["service"], lines)
if err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonResponse(w, http.StatusOK, logs)
}
}
func handleStreamLogs(sm *services.ServiceManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// WebSocket streaming would be implemented here
jsonError(w, http.StatusNotImplemented, "WebSocket streaming not yet implemented")
}
}
func handleDownloadBundle(sm *services.ServiceManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
bundle, err := sm.Logs.GenerateSupportBundle()
if err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", "attachment; filename=bams-support-bundle.zip")
w.Write(bundle)
}
}
// Helper functions
func jsonResponse(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if data != nil {
json.NewEncoder(w).Encode(data)
}
}
func jsonError(w http.ResponseWriter, status int, message string) {
jsonResponse(w, status, map[string]string{"error": message})
}

View File

@@ -0,0 +1,87 @@
package api
import (
"net/http"
"github.com/bams/backend/internal/logger"
"github.com/bams/backend/internal/services"
"github.com/gorilla/mux"
)
func NewRouter(sm *services.ServiceManager, log *logger.Logger) *mux.Router {
router := mux.NewRouter()
// Middleware
router.Use(loggingMiddleware(log))
router.Use(corsMiddleware())
// API v1 routes
v1 := router.PathPrefix("/api/v1").Subrouter()
// Dashboard
v1.HandleFunc("/dashboard", handleDashboard(sm)).Methods("GET")
// Disk repository management
v1.HandleFunc("/disk/repositories", handleListRepositories(sm)).Methods("GET")
v1.HandleFunc("/disk/repositories", handleCreateRepository(sm)).Methods("POST")
v1.HandleFunc("/disk/repositories/{id}", handleGetRepository(sm)).Methods("GET")
v1.HandleFunc("/disk/repositories/{id}", handleDeleteRepository(sm)).Methods("DELETE")
// Tape library management
v1.HandleFunc("/tape/library", handleGetLibrary(sm)).Methods("GET")
v1.HandleFunc("/tape/inventory", handleInventory(sm)).Methods("POST")
v1.HandleFunc("/tape/drives", handleListDrives(sm)).Methods("GET")
v1.HandleFunc("/tape/drives/{id}/load", handleLoadTape(sm)).Methods("POST")
v1.HandleFunc("/tape/drives/{id}/unload", handleUnloadTape(sm)).Methods("POST")
v1.HandleFunc("/tape/slots", handleListSlots(sm)).Methods("GET")
// iSCSI target management
v1.HandleFunc("/iscsi/targets", handleListTargets(sm)).Methods("GET")
v1.HandleFunc("/iscsi/targets", handleCreateTarget(sm)).Methods("POST")
v1.HandleFunc("/iscsi/targets/{id}", handleGetTarget(sm)).Methods("GET")
v1.HandleFunc("/iscsi/targets/{id}", handleUpdateTarget(sm)).Methods("PUT")
v1.HandleFunc("/iscsi/targets/{id}", handleDeleteTarget(sm)).Methods("DELETE")
v1.HandleFunc("/iscsi/targets/{id}/apply", handleApplyTarget(sm)).Methods("POST")
v1.HandleFunc("/iscsi/sessions", handleListSessions(sm)).Methods("GET")
// Bacula integration
v1.HandleFunc("/bacula/status", handleBaculaStatus(sm)).Methods("GET")
v1.HandleFunc("/bacula/config", handleGetBaculaConfig(sm)).Methods("GET")
v1.HandleFunc("/bacula/config", handleGenerateBaculaConfig(sm)).Methods("POST")
v1.HandleFunc("/bacula/inventory", handleBaculaInventory(sm)).Methods("POST")
v1.HandleFunc("/bacula/restart", handleBaculaRestart(sm)).Methods("POST")
// Logs and diagnostics
v1.HandleFunc("/logs/{service}", handleGetLogs(sm)).Methods("GET")
v1.HandleFunc("/logs/{service}/stream", handleStreamLogs(sm)).Methods("GET")
v1.HandleFunc("/diagnostics/bundle", handleDownloadBundle(sm)).Methods("GET")
// Health check
router.HandleFunc("/health", handleHealth()).Methods("GET")
return router
}
func loggingMiddleware(log *logger.Logger) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Info("HTTP request", "method", r.Method, "path", r.URL.Path)
next.ServeHTTP(w, r)
})
}
}
func corsMiddleware() mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
}

View File

@@ -0,0 +1,61 @@
package config
import (
"fmt"
"os"
"strconv"
"gopkg.in/yaml.v3"
)
type Config struct {
Port int `yaml:"port"`
LogLevel string `yaml:"log_level"`
DataDir string `yaml:"data_dir"`
SCSTConfig string `yaml:"scst_config"`
BaculaConfig string `yaml:"bacula_config"`
Security SecurityConfig `yaml:"security"`
}
type SecurityConfig struct {
RequireHTTPS bool `yaml:"require_https"`
AllowedUsers []string `yaml:"allowed_users"`
}
func Load() (*Config, error) {
cfg := &Config{
Port: 8080,
LogLevel: "info",
DataDir: "/var/lib/bams",
SCSTConfig: "/etc/scst.conf",
BaculaConfig: "/etc/bacula/bacula-sd.conf",
Security: SecurityConfig{
RequireHTTPS: false,
AllowedUsers: []string{},
},
}
// Load from environment or config file
if configPath := os.Getenv("BAMS_CONFIG"); configPath != "" {
data, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("failed to parse config file: %w", err)
}
}
// Override with environment variables
if portStr := os.Getenv("BAMS_PORT"); portStr != "" {
if port, err := strconv.Atoi(portStr); err == nil {
cfg.Port = port
}
}
if logLevel := os.Getenv("BAMS_LOG_LEVEL"); logLevel != "" {
cfg.LogLevel = logLevel
}
return cfg, nil
}

View File

@@ -0,0 +1,60 @@
package logger
import (
"log"
"os"
)
type Logger struct {
level string
log *log.Logger
}
func New(level string) *Logger {
return &Logger{
level: level,
log: log.New(os.Stdout, "[BAMS] ", log.LstdFlags|log.Lshortfile),
}
}
func (l *Logger) Info(msg string, fields ...interface{}) {
if l.shouldLog("info") {
l.log.Printf("[INFO] %s %v", msg, fields)
}
}
func (l *Logger) Error(msg string, fields ...interface{}) {
if l.shouldLog("error") {
l.log.Printf("[ERROR] %s %v", msg, fields)
}
}
func (l *Logger) Debug(msg string, fields ...interface{}) {
if l.shouldLog("debug") {
l.log.Printf("[DEBUG] %s %v", msg, fields)
}
}
func (l *Logger) Warn(msg string, fields ...interface{}) {
if l.shouldLog("warn") {
l.log.Printf("[WARN] %s %v", msg, fields)
}
}
func (l *Logger) shouldLog(level string) bool {
levels := map[string]int{
"debug": 0,
"info": 1,
"warn": 2,
"error": 3,
}
currentLevel, ok := levels[l.level]
if !ok {
currentLevel = 1
}
msgLevel, ok := levels[level]
if !ok {
return true
}
return msgLevel >= currentLevel
}

View File

@@ -0,0 +1,74 @@
package audit
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"github.com/bams/backend/internal/config"
"github.com/bams/backend/internal/logger"
)
type AuditEntry struct {
Timestamp time.Time `json:"timestamp"`
User string `json:"user"`
Action string `json:"action"`
Resource string `json:"resource"`
Result string `json:"result"` // "success" or "failure"
Details string `json:"details,omitempty"`
}
type Service struct {
config *config.Config
logger *logger.Logger
logFile *os.File
}
func NewService(cfg *config.Config, log *logger.Logger) *Service {
auditDir := filepath.Join(cfg.DataDir, "audit")
os.MkdirAll(auditDir, 0755)
logPath := filepath.Join(auditDir, fmt.Sprintf("audit-%s.log", time.Now().Format("2006-01-02")))
file, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0640)
if err != nil {
log.Error("Failed to open audit log", "error", err)
}
return &Service{
config: cfg,
logger: log,
logFile: file,
}
}
func (s *Service) Log(user, action, resource, result, details string) {
entry := AuditEntry{
Timestamp: time.Now(),
User: user,
Action: action,
Resource: resource,
Result: result,
Details: details,
}
data, err := json.Marshal(entry)
if err != nil {
s.logger.Error("Failed to marshal audit entry", "error", err)
return
}
if s.logFile != nil {
s.logFile.WriteString(string(data) + "\n")
s.logFile.Sync()
}
s.logger.Info("Audit log", "user", user, "action", action, "resource", resource, "result", result)
}
func (s *Service) Close() {
if s.logFile != nil {
s.logFile.Close()
}
}

View File

@@ -0,0 +1,130 @@
package bacula
import (
"fmt"
"os"
"os/exec"
"strings"
"github.com/bams/backend/internal/config"
"github.com/bams/backend/internal/logger"
)
type Status struct {
Status string `json:"status"` // "running", "stopped", "unknown"
PID int `json:"pid,omitempty"`
Version string `json:"version,omitempty"`
LastError string `json:"last_error,omitempty"`
}
type Service struct {
config *config.Config
logger *logger.Logger
}
func NewService(cfg *config.Config, log *logger.Logger) *Service {
return &Service{
config: cfg,
logger: log,
}
}
func (s *Service) GetStatus() (*Status, error) {
s.logger.Debug("Getting Bacula SD status")
status := &Status{
Status: "unknown",
}
// Check if bacula-sd process is running
cmd := exec.Command("systemctl", "is-active", "bacula-sd")
output, err := cmd.CombinedOutput()
if err == nil {
if strings.TrimSpace(string(output)) == "active" {
status.Status = "running"
} else {
status.Status = "stopped"
}
}
return status, nil
}
func (s *Service) GetConfig() (string, error) {
s.logger.Debug("Getting Bacula SD config")
if _, err := os.Stat(s.config.BaculaConfig); err != nil {
return "", fmt.Errorf("config file not found: %w", err)
}
data, err := os.ReadFile(s.config.BaculaConfig)
if err != nil {
return "", fmt.Errorf("failed to read config: %w", err)
}
return string(data), nil
}
func (s *Service) GenerateConfig() (string, error) {
s.logger.Info("Generating Bacula SD config")
// Generate template configuration
config := `# Bacula Storage Daemon Configuration
# Generated by BAMS
Storage {
Name = bacula-sd
WorkingDirectory = /var/lib/bacula
PidDirectory = /run/bacula
Maximum Concurrent Jobs = 20
SDAddress = 0.0.0.0
}
Director {
Name = bacula-dir
Password = "changeme"
}
Device {
Name = FileStorage
Media Type = File
Archive Device = /var/lib/bacula/storage
LabelMedia = yes
Random Access = yes
AutomaticMount = yes
RemovableMedia = no
AlwaysOpen = no
}
# Autochanger configuration will be added here
`
return config, nil
}
func (s *Service) RunInventory() error {
s.logger.Info("Running Bacula inventory")
// Use bconsole to run inventory
cmd := exec.Command("bconsole", "-c", "update slots")
output, err := cmd.CombinedOutput()
if err != nil {
s.logger.Error("Failed to run inventory", "error", string(output))
return fmt.Errorf("inventory failed: %w", err)
}
return nil
}
func (s *Service) Restart() error {
s.logger.Info("Restarting Bacula SD")
cmd := exec.Command("systemctl", "restart", "bacula-sd")
output, err := cmd.CombinedOutput()
if err != nil {
s.logger.Error("Failed to restart Bacula SD", "error", string(output))
return fmt.Errorf("restart failed: %w", err)
}
return nil
}

View File

@@ -0,0 +1,101 @@
package disk
import (
"fmt"
"os/exec"
"github.com/bams/backend/internal/config"
"github.com/bams/backend/internal/logger"
)
type Repository struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"` // "lvm" or "zfs"
Size string `json:"size"`
Used string `json:"used"`
MountPoint string `json:"mount_point"`
Status string `json:"status"`
VGName string `json:"vg_name,omitempty"`
PoolName string `json:"pool_name,omitempty"`
}
type Service struct {
config *config.Config
logger *logger.Logger
}
func NewService(cfg *config.Config, log *logger.Logger) *Service {
return &Service{
config: cfg,
logger: log,
}
}
func (s *Service) ListRepositories() ([]*Repository, error) {
// TODO: Implement actual LVM/ZFS listing
s.logger.Debug("Listing disk repositories")
return []*Repository{}, nil
}
func (s *Service) GetRepository(id string) (*Repository, error) {
// TODO: Implement actual repository retrieval
s.logger.Debug("Getting repository", "id", id)
return nil, fmt.Errorf("repository not found: %s", id)
}
func (s *Service) CreateRepository(name, size, repoType, vgName, poolName string) (*Repository, error) {
s.logger.Info("Creating repository", "name", name, "size", size, "type", repoType)
var err error
if repoType == "lvm" {
err = s.createLVMRepository(name, size, vgName)
} else if repoType == "zfs" {
err = s.createZFSRepository(name, size, poolName)
} else {
return nil, fmt.Errorf("unsupported repository type: %s", repoType)
}
if err != nil {
return nil, err
}
return &Repository{
ID: name,
Name: name,
Type: repoType,
Size: size,
Status: "active",
VGName: vgName,
PoolName: poolName,
}, nil
}
func (s *Service) createLVMRepository(name, size, vgName string) error {
// Create LVM logical volume
cmd := exec.Command("lvcreate", "-L", size, "-n", name, vgName)
output, err := cmd.CombinedOutput()
if err != nil {
s.logger.Error("Failed to create LVM volume", "error", string(output))
return fmt.Errorf("failed to create LVM volume: %w", err)
}
return nil
}
func (s *Service) createZFSRepository(name, size, poolName string) error {
// Create ZFS zvol
zvolPath := fmt.Sprintf("%s/%s", poolName, name)
cmd := exec.Command("zfs", "create", "-V", size, zvolPath)
output, err := cmd.CombinedOutput()
if err != nil {
s.logger.Error("Failed to create ZFS zvol", "error", string(output))
return fmt.Errorf("failed to create ZFS zvol: %w", err)
}
return nil
}
func (s *Service) DeleteRepository(id string) error {
s.logger.Info("Deleting repository", "id", id)
// TODO: Implement actual deletion
return nil
}

View File

@@ -0,0 +1,222 @@
package iscsi
import (
"fmt"
"os"
"os/exec"
"strings"
"github.com/bams/backend/internal/config"
"github.com/bams/backend/internal/logger"
)
type Target struct {
ID string `json:"id"`
IQN string `json:"iqn"`
Portals []string `json:"portals"`
Initiators []string `json:"initiators"`
LUNs []LUN `json:"luns"`
Status string `json:"status"`
}
type LUN struct {
Number int `json:"number"`
Device string `json:"device"`
Type string `json:"type"` // "disk" or "tape"
}
type Session struct {
TargetIQN string `json:"target_iqn"`
InitiatorIQN string `json:"initiator_iqn"`
IP string `json:"ip"`
State string `json:"state"`
}
type Service struct {
config *config.Config
logger *logger.Logger
}
func NewService(cfg *config.Config, log *logger.Logger) *Service {
return &Service{
config: cfg,
logger: log,
}
}
func (s *Service) ListTargets() ([]*Target, error) {
s.logger.Debug("Listing iSCSI targets")
// Read SCST configuration
targets := []*Target{}
// Parse SCST config file
if _, err := os.Stat(s.config.SCSTConfig); err == nil {
data, err := os.ReadFile(s.config.SCSTConfig)
if err == nil {
targets = s.parseSCSTConfig(string(data))
}
}
return targets, nil
}
func (s *Service) GetTarget(id string) (*Target, error) {
targets, err := s.ListTargets()
if err != nil {
return nil, err
}
for _, target := range targets {
if target.ID == id || target.IQN == id {
return target, nil
}
}
return nil, fmt.Errorf("target not found: %s", id)
}
func (s *Service) CreateTarget(iqn string, portals, initiators []string) (*Target, error) {
s.logger.Info("Creating iSCSI target", "iqn", iqn)
target := &Target{
ID: iqn,
IQN: iqn,
Portals: portals,
Initiators: initiators,
LUNs: []LUN{},
Status: "pending",
}
// Write to SCST config
if err := s.writeSCSTConfig(target); err != nil {
return nil, fmt.Errorf("failed to write SCST config: %w", err)
}
return target, nil
}
func (s *Service) UpdateTarget(id string, portals, initiators []string) (*Target, error) {
target, err := s.GetTarget(id)
if err != nil {
return nil, err
}
target.Portals = portals
target.Initiators = initiators
if err := s.writeSCSTConfig(target); err != nil {
return nil, fmt.Errorf("failed to update SCST config: %w", err)
}
return target, nil
}
func (s *Service) DeleteTarget(id string) error {
s.logger.Info("Deleting iSCSI target", "id", id)
// Remove from SCST config
// TODO: Implement actual deletion
return nil
}
func (s *Service) ApplyTarget(id string) error {
s.logger.Info("Applying iSCSI target configuration", "id", id)
// Reload SCST configuration
cmd := exec.Command("scstadmin", "-config", s.config.SCSTConfig)
output, err := cmd.CombinedOutput()
if err != nil {
s.logger.Error("Failed to apply SCST config", "error", string(output))
return fmt.Errorf("failed to apply SCST config: %w", err)
}
return nil
}
func (s *Service) ListSessions() ([]*Session, error) {
s.logger.Debug("Listing iSCSI sessions")
sessions := []*Session{}
// Query SCST for active sessions
cmd := exec.Command("scstadmin", "-list_sessions")
output, err := cmd.CombinedOutput()
if err == nil {
sessions = s.parseSCSTSessions(string(output))
}
return sessions, nil
}
func (s *Service) parseSCSTConfig(data string) []*Target {
targets := []*Target{}
// Simplified parsing - real implementation would parse SCST config format
lines := strings.Split(data, "\n")
currentTarget := &Target{}
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "TARGET") {
if currentTarget.IQN != "" {
targets = append(targets, currentTarget)
}
currentTarget = &Target{
IQN: strings.Fields(line)[1],
ID: strings.Fields(line)[1],
Portals: []string{},
Initiators: []string{},
LUNs: []LUN{},
Status: "active",
}
}
}
if currentTarget.IQN != "" {
targets = append(targets, currentTarget)
}
return targets
}
func (s *Service) parseSCSTSessions(data string) []*Session {
sessions := []*Session{}
// Parse SCST session output
lines := strings.Split(data, "\n")
for _, line := range lines {
if strings.Contains(line, "session") {
// Parse session information
session := &Session{
State: "active",
}
sessions = append(sessions, session)
}
}
return sessions
}
func (s *Service) writeSCSTConfig(target *Target) error {
// Generate SCST configuration
config := fmt.Sprintf(`TARGET %s {
enabled 1
`, target.IQN)
for _, portal := range target.Portals {
config += fmt.Sprintf(" portal %s {\n", portal)
config += " enabled 1\n"
config += " }\n"
}
for _, initiator := range target.Initiators {
config += fmt.Sprintf(" initiator %s {\n", initiator)
config += " enabled 1\n"
config += " }\n"
}
config += "}\n"
// Append to config file (simplified - real implementation would merge properly)
return os.WriteFile(s.config.SCSTConfig, []byte(config), 0644)
}

View File

@@ -0,0 +1,136 @@
package logs
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/bams/backend/internal/config"
"github.com/bams/backend/internal/logger"
)
type LogEntry struct {
Timestamp time.Time `json:"timestamp"`
Level string `json:"level"`
Message string `json:"message"`
}
type Service struct {
config *config.Config
logger *logger.Logger
}
func NewService(cfg *config.Config, log *logger.Logger) *Service {
return &Service{
config: cfg,
logger: log,
}
}
func (s *Service) GetLogs(service string, lines int) ([]*LogEntry, error) {
var logPath string
switch service {
case "scst":
logPath = "/var/log/scst.log"
case "iscsi":
logPath = "/var/log/kern.log" // iSCSI logs often in kernel log
case "bacula":
logPath = "/var/log/bacula/bacula.log"
case "bams":
logPath = "/var/log/bams/bams.log"
default:
return nil, fmt.Errorf("unknown service: %s", service)
}
if _, err := os.Stat(logPath); err != nil {
return []*LogEntry{}, nil
}
data, err := os.ReadFile(logPath)
if err != nil {
return nil, fmt.Errorf("failed to read log file: %w", err)
}
entries := []*LogEntry{}
logLines := strings.Split(string(data), "\n")
// Get last N lines
start := 0
if len(logLines) > lines {
start = len(logLines) - lines
}
for i := start; i < len(logLines); i++ {
if logLines[i] == "" {
continue
}
entry := &LogEntry{
Timestamp: time.Now(), // Simplified - would parse from log line
Level: "info",
Message: logLines[i],
}
entries = append(entries, entry)
}
return entries, nil
}
func (s *Service) GenerateSupportBundle() ([]byte, error) {
s.logger.Info("Generating support bundle")
// Create temporary directory
tmpDir := "/tmp/bams-bundle"
os.MkdirAll(tmpDir, 0755)
// Collect logs
logs := []string{"scst", "iscsi", "bacula", "bams"}
for _, service := range logs {
logPath := s.getLogPath(service)
if _, err := os.Stat(logPath); err == nil {
data, _ := os.ReadFile(logPath)
os.WriteFile(filepath.Join(tmpDir, fmt.Sprintf("%s.log", service)), data, 0644)
}
}
// Collect configs
configs := map[string]string{
"scst.conf": s.config.SCSTConfig,
"bacula-sd.conf": s.config.BaculaConfig,
}
for name, path := range configs {
if _, err := os.Stat(path); err == nil {
data, _ := os.ReadFile(path)
os.WriteFile(filepath.Join(tmpDir, name), data, 0644)
}
}
// Collect system info
systemInfo := fmt.Sprintf("OS: %s\n", "Linux")
os.WriteFile(filepath.Join(tmpDir, "system-info.txt"), []byte(systemInfo), 0644)
// Create zip (simplified - would use archive/zip)
bundleData := []byte("Support bundle placeholder")
// Cleanup
os.RemoveAll(tmpDir)
return bundleData, nil
}
func (s *Service) getLogPath(service string) string {
switch service {
case "scst":
return "/var/log/scst.log"
case "iscsi":
return "/var/log/kern.log"
case "bacula":
return "/var/log/bacula/bacula.log"
case "bams":
return "/var/log/bams/bams.log"
default:
return ""
}
}

View File

@@ -0,0 +1,42 @@
package services
import (
"github.com/bams/backend/internal/config"
"github.com/bams/backend/internal/logger"
"github.com/bams/backend/internal/services/bacula"
"github.com/bams/backend/internal/services/disk"
"github.com/bams/backend/internal/services/iscsi"
"github.com/bams/backend/internal/services/logs"
"github.com/bams/backend/internal/services/tape"
)
type ServiceManager struct {
Config *config.Config
Logger *logger.Logger
Disk *disk.Service
ISCSI *iscsi.Service
Tape *tape.Service
Bacula *bacula.Service
Logs *logs.Service
}
func NewServiceManager(cfg *config.Config, log *logger.Logger) *ServiceManager {
sm := &ServiceManager{
Config: cfg,
Logger: log,
}
// Initialize services
sm.Disk = disk.NewService(cfg, log)
sm.ISCSI = iscsi.NewService(cfg, log)
sm.Tape = tape.NewService(cfg, log)
sm.Bacula = bacula.NewService(cfg, log)
sm.Logs = logs.NewService(cfg, log)
return sm
}
func (sm *ServiceManager) Shutdown() {
sm.Logger.Info("Shutting down services...")
// Graceful shutdown of all services
}

View File

@@ -0,0 +1,183 @@
package tape
import (
"fmt"
"os/exec"
"strconv"
"strings"
"github.com/bams/backend/internal/config"
"github.com/bams/backend/internal/logger"
)
type Library struct {
Status string `json:"status"`
TotalSlots int `json:"total_slots"`
ActiveDrives int `json:"active_drives"`
Model string `json:"model"`
Serial string `json:"serial"`
}
type Drive struct {
ID string `json:"id"`
Status string `json:"status"`
LoadedTape string `json:"loaded_tape,omitempty"`
Barcode string `json:"barcode,omitempty"`
Position int `json:"position"`
}
type Slot struct {
Number int `json:"number"`
Barcode string `json:"barcode,omitempty"`
Status string `json:"status"` // "empty", "loaded", "import"
}
type Service struct {
config *config.Config
logger *logger.Logger
}
func NewService(cfg *config.Config, log *logger.Logger) *Service {
return &Service{
config: cfg,
logger: log,
}
}
func (s *Service) GetLibrary() (*Library, error) {
s.logger.Debug("Getting tape library info")
// Try to detect library using mtx or sg_lib
library := &Library{
Status: "unknown",
TotalSlots: 0,
ActiveDrives: 0,
}
// Check for mtx (media changer)
cmd := exec.Command("mtx", "-f", "/dev/sg0", "status")
output, err := cmd.CombinedOutput()
if err == nil {
// Parse mtx output
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if strings.Contains(line, "Storage Element") {
library.TotalSlots++
}
if strings.Contains(line, "Data Transfer Element") {
library.ActiveDrives++
}
}
library.Status = "online"
}
return library, nil
}
func (s *Service) RunInventory() error {
s.logger.Info("Running tape library inventory")
// Use mtx to inventory
cmd := exec.Command("mtx", "-f", "/dev/sg0", "inventory")
output, err := cmd.CombinedOutput()
if err != nil {
s.logger.Error("Failed to run inventory", "error", string(output))
return fmt.Errorf("inventory failed: %w", err)
}
return nil
}
func (s *Service) ListDrives() ([]*Drive, error) {
s.logger.Debug("Listing tape drives")
drives := []*Drive{}
// Detect drives (up to 8)
for i := 0; i < 8; i++ {
device := fmt.Sprintf("/dev/nst%d", i)
cmd := exec.Command("mt", "-f", device, "status")
output, err := cmd.CombinedOutput()
if err == nil {
drive := &Drive{
ID: fmt.Sprintf("drive-%d", i),
Status: "online",
Position: i,
}
// Check if tape is loaded
if strings.Contains(string(output), "ONLINE") {
drive.Status = "loaded"
}
drives = append(drives, drive)
}
}
return drives, nil
}
func (s *Service) LoadTape(driveID string, slot int) error {
s.logger.Info("Loading tape", "drive", driveID, "slot", slot)
// Extract drive number from ID
driveNum := 0
fmt.Sscanf(driveID, "drive-%d", &driveNum)
// Use mtx to load
cmd := exec.Command("mtx", "-f", "/dev/sg0", "load", strconv.Itoa(slot), strconv.Itoa(driveNum))
output, err := cmd.CombinedOutput()
if err != nil {
s.logger.Error("Failed to load tape", "error", string(output))
return fmt.Errorf("load failed: %w", err)
}
return nil
}
func (s *Service) UnloadTape(driveID string, slot int) error {
s.logger.Info("Unloading tape", "drive", driveID, "slot", slot)
// Extract drive number from ID
driveNum := 0
fmt.Sscanf(driveID, "drive-%d", &driveNum)
// Use mtx to unload
cmd := exec.Command("mtx", "-f", "/dev/sg0", "unload", strconv.Itoa(slot), strconv.Itoa(driveNum))
output, err := cmd.CombinedOutput()
if err != nil {
s.logger.Error("Failed to unload tape", "error", string(output))
return fmt.Errorf("unload failed: %w", err)
}
return nil
}
func (s *Service) ListSlots() ([]*Slot, error) {
s.logger.Debug("Listing tape slots")
slots := []*Slot{}
// Use mtx to get slot status
cmd := exec.Command("mtx", "-f", "/dev/sg0", "status")
output, err := cmd.CombinedOutput()
if err != nil {
return slots, fmt.Errorf("failed to get slot status: %w", err)
}
// Parse mtx output
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if strings.Contains(line, "Storage Element") {
// Parse slot information
slot := &Slot{
Status: "empty",
}
// Extract slot number and barcode from line
// This is simplified - real parsing would be more complex
slots = append(slots, slot)
}
}
return slots, nil
}

68
backend/main.go Normal file
View File

@@ -0,0 +1,68 @@
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/bams/backend/internal/api"
"github.com/bams/backend/internal/config"
"github.com/bams/backend/internal/logger"
"github.com/bams/backend/internal/services"
)
func main() {
// Load configuration
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
// Initialize logger
logger := logger.New(cfg.LogLevel)
// Initialize services
svcManager := services.NewServiceManager(cfg, logger)
// Initialize API router
router := api.NewRouter(svcManager, logger)
// Create HTTP server
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Port),
Handler: router,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
// Start server in goroutine
go func() {
logger.Info("Starting BAMS backend server", "port", cfg.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("Server failed to start", "error", err)
os.Exit(1)
}
}()
// Graceful shutdown
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
logger.Info("Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
logger.Error("Server forced to shutdown", "error", err)
}
svcManager.Shutdown()
logger.Info("Server exited")
}

354
cockpit/bams.js Normal file
View File

@@ -0,0 +1,354 @@
(function() {
"use strict";
const API_BASE = "http://localhost:8080/api/v1";
let currentTab = "dashboard";
// Initialize
$(document).ready(function() {
setupTabs();
loadDashboard();
setupEventHandlers();
});
function setupTabs() {
$("a[data-tab]").on("click", function(e) {
e.preventDefault();
const tab = $(this).data("tab");
switchTab(tab);
});
}
function switchTab(tab) {
$(".tab-content").hide();
$(".nav li").removeClass("active");
$(`#${tab}`).show();
$(`a[data-tab="${tab}"]`).parent().addClass("active");
currentTab = tab;
// Load tab-specific data
switch(tab) {
case "dashboard":
loadDashboard();
break;
case "storage":
loadRepositories();
break;
case "tape":
loadTapeLibrary();
break;
case "iscsi":
loadiSCSITargets();
break;
case "bacula":
loadBaculaStatus();
break;
case "logs":
loadLogs();
break;
}
}
function apiCall(endpoint, method, data) {
return new Promise((resolve, reject) => {
const options = {
url: `${API_BASE}${endpoint}`,
method: method || "GET",
contentType: "application/json",
success: resolve,
error: function(xhr, status, error) {
reject(new Error(error || xhr.responseText));
}
};
if (data) {
options.data = JSON.stringify(data);
}
$.ajax(options);
});
}
function loadDashboard() {
apiCall("/dashboard")
.then(data => {
// Update disk stats
const disk = data.disk || {};
$("#disk-stats").html(`
<p><strong>Repositories:</strong> ${disk.repositories || 0}</p>
<p><strong>Total:</strong> ${formatBytes(disk.total_capacity || 0)}</p>
<p><strong>Used:</strong> ${formatBytes(disk.used_capacity || 0)}</p>
`);
// Update tape stats
const tape = data.tape || {};
$("#tape-stats").html(`
<p><strong>Status:</strong> ${tape.library_status || "unknown"}</p>
<p><strong>Active Drives:</strong> ${tape.drives_active || 0}</p>
<p><strong>Total Slots:</strong> ${tape.total_slots || 0}</p>
`);
// Update iSCSI stats
const iscsi = data.iscsi || {};
$("#iscsi-stats").html(`
<p><strong>Targets:</strong> ${iscsi.targets || 0}</p>
<p><strong>Sessions:</strong> ${iscsi.sessions || 0}</p>
`);
// Update Bacula stats
const bacula = data.bacula || {};
$("#bacula-stats").html(`
<p><strong>Status:</strong> ${bacula.status || "unknown"}</p>
`);
// Update alerts
const alerts = data.alerts || [];
if (alerts.length > 0) {
let alertsHtml = "<ul>";
alerts.forEach(alert => {
alertsHtml += `<li class="alert alert-${alert.level || 'warning'}">${alert.message}</li>`;
});
alertsHtml += "</ul>";
$("#alerts").html(alertsHtml);
} else {
$("#alerts").html("<p>No alerts</p>");
}
})
.catch(err => {
console.error("Failed to load dashboard:", err);
$("#disk-stats, #tape-stats, #iscsi-stats, #bacula-stats").html("<p>Error loading data</p>");
});
}
function loadRepositories() {
apiCall("/disk/repositories")
.then(repos => {
let html = "";
if (repos.length === 0) {
html = "<tr><td colspan='6'>No repositories</td></tr>";
} else {
repos.forEach(repo => {
html += `
<tr>
<td>${repo.name}</td>
<td>${repo.type}</td>
<td>${repo.size}</td>
<td>${repo.used || "N/A"}</td>
<td><span class="label label-${repo.status === 'active' ? 'success' : 'default'}">${repo.status}</span></td>
<td>
<button class="btn btn-sm btn-danger" onclick="deleteRepository('${repo.id}')">Delete</button>
</td>
</tr>
`;
});
}
$("#repositories-table").html(html);
})
.catch(err => {
console.error("Failed to load repositories:", err);
$("#repositories-table").html("<tr><td colspan='6'>Error loading repositories</td></tr>");
});
}
function loadTapeLibrary() {
Promise.all([
apiCall("/tape/library"),
apiCall("/tape/drives"),
apiCall("/tape/slots")
]).then(([library, drives, slots]) => {
// Library status
$("#library-status").html(`
<p><strong>Status:</strong> ${library.status}</p>
<p><strong>Model:</strong> ${library.model || "N/A"}</p>
<p><strong>Total Slots:</strong> ${library.total_slots}</p>
<p><strong>Active Drives:</strong> ${library.active_drives}</p>
`);
// Drives
let drivesHtml = "";
if (drives.length === 0) {
drivesHtml = "<p>No drives detected</p>";
} else {
drives.forEach(drive => {
drivesHtml += `
<div class="drive-item">
<strong>${drive.id}</strong>: ${drive.status}
${drive.loaded_tape ? ` (Tape: ${drive.barcode || drive.loaded_tape})` : ""}
</div>
`;
});
}
$("#drives-list").html(drivesHtml);
// Slots
let slotsHtml = "<table class='table'><thead><tr><th>Slot</th><th>Barcode</th><th>Status</th></tr></thead><tbody>";
if (slots.length === 0) {
slotsHtml += "<tr><td colspan='3'>No slots</td></tr>";
} else {
slots.forEach(slot => {
slotsHtml += `
<tr>
<td>${slot.number}</td>
<td>${slot.barcode || "N/A"}</td>
<td>${slot.status}</td>
</tr>
`;
});
}
slotsHtml += "</tbody></table>";
$("#slots-list").html(slotsHtml);
}).catch(err => {
console.error("Failed to load tape library:", err);
});
}
function loadiSCSITargets() {
Promise.all([
apiCall("/iscsi/targets"),
apiCall("/iscsi/sessions")
]).then(([targets, sessions]) => {
// Targets
let targetsHtml = "";
if (targets.length === 0) {
targetsHtml = "<tr><td colspan='5'>No targets</td></tr>";
} else {
targets.forEach(target => {
targetsHtml += `
<tr>
<td>${target.iqn}</td>
<td>${target.portals.join(", ") || "N/A"}</td>
<td>${target.initiators.join(", ") || "N/A"}</td>
<td><span class="label label-${target.status === 'active' ? 'success' : 'default'}">${target.status}</span></td>
<td>
<button class="btn btn-sm btn-primary" onclick="applyTarget('${target.id}')">Apply</button>
<button class="btn btn-sm btn-danger" onclick="deleteTarget('${target.id}')">Delete</button>
</td>
</tr>
`;
});
}
$("#targets-table").html(targetsHtml);
// Sessions
let sessionsHtml = "";
if (sessions.length === 0) {
sessionsHtml = "<tr><td colspan='4'>No active sessions</td></tr>";
} else {
sessions.forEach(session => {
sessionsHtml += `
<tr>
<td>${session.target_iqn}</td>
<td>${session.initiator_iqn}</td>
<td>${session.ip}</td>
<td>${session.state}</td>
</tr>
`;
});
}
$("#sessions-table").html(sessionsHtml);
}).catch(err => {
console.error("Failed to load iSCSI targets:", err);
});
}
function loadBaculaStatus() {
apiCall("/bacula/status")
.then(status => {
$("#bacula-status").html(`
<p><strong>Status:</strong> <span class="label label-${status.status === 'running' ? 'success' : 'danger'}">${status.status}</span></p>
${status.version ? `<p><strong>Version:</strong> ${status.version}</p>` : ""}
${status.pid ? `<p><strong>PID:</strong> ${status.pid}</p>` : ""}
`);
})
.catch(err => {
console.error("Failed to load Bacula status:", err);
});
}
function loadLogs() {
const service = $("#log-service").val();
apiCall(`/logs/${service}?lines=100`)
.then(logs => {
let logsHtml = "";
logs.forEach(entry => {
logsHtml += `[${entry.timestamp}] ${entry.level}: ${entry.message}\n`;
});
$("#logs-content").text(logsHtml);
})
.catch(err => {
console.error("Failed to load logs:", err);
$("#logs-content").text("Error loading logs: " + err.message);
});
}
function setupEventHandlers() {
$("#inventory-btn").on("click", function() {
apiCall("/tape/inventory", "POST")
.then(() => {
alert("Inventory started");
loadTapeLibrary();
})
.catch(err => alert("Failed to start inventory: " + err.message));
});
$("#bacula-inventory-btn").on("click", function() {
apiCall("/bacula/inventory", "POST")
.then(() => alert("Inventory started"))
.catch(err => alert("Failed to start inventory: " + err.message));
});
$("#bacula-restart-btn").on("click", function() {
if (confirm("Restart Bacula Storage Daemon?")) {
apiCall("/bacula/restart", "POST")
.then(() => {
alert("Restart initiated");
setTimeout(loadBaculaStatus, 2000);
})
.catch(err => alert("Failed to restart: " + err.message));
}
});
$("#refresh-logs-btn").on("click", loadLogs);
$("#log-service").on("change", loadLogs);
$("#download-bundle-btn").on("click", function() {
window.location.href = `${API_BASE}/diagnostics/bundle`;
});
}
function formatBytes(bytes) {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + " " + sizes[i];
}
// Global functions for inline handlers
window.deleteRepository = function(id) {
if (confirm("Delete this repository?")) {
apiCall(`/disk/repositories/${id}`, "DELETE")
.then(() => {
alert("Repository deleted");
loadRepositories();
})
.catch(err => alert("Failed to delete: " + err.message));
}
};
window.applyTarget = function(id) {
apiCall(`/iscsi/targets/${id}/apply`, "POST")
.then(() => alert("Target configuration applied"))
.catch(err => alert("Failed to apply: " + err.message));
};
window.deleteTarget = function(id) {
if (confirm("Delete this iSCSI target?")) {
apiCall(`/iscsi/targets/${id}`, "DELETE")
.then(() => {
alert("Target deleted");
loadiSCSITargets();
})
.catch(err => alert("Failed to delete: " + err.message));
}
};
})();

250
cockpit/index.html Normal file
View File

@@ -0,0 +1,250 @@
<!DOCTYPE html>
<html>
<head>
<title>BAMS - Backup Appliance Management System</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="../base1/cockpit.css" type="text/css" rel="stylesheet">
<script src="../base1/jquery.js"></script>
<script src="../base1/cockpit.js"></script>
</head>
<body>
<div class="container-fluid">
<nav class="navbar navbar-default">
<div class="container-fluid">
<div class="navbar-header">
<a class="navbar-brand" href="#">BAMS</a>
</div>
<ul class="nav navbar-nav">
<li class="active"><a href="#dashboard" data-tab="dashboard">Dashboard</a></li>
<li><a href="#storage" data-tab="storage">Storage</a></li>
<li><a href="#tape" data-tab="tape">Tape Library</a></li>
<li><a href="#iscsi" data-tab="iscsi">iSCSI Targets</a></li>
<li><a href="#bacula" data-tab="bacula">Bacula</a></li>
<li><a href="#logs" data-tab="logs">Logs</a></li>
</ul>
</div>
</nav>
<div id="dashboard" class="tab-content active">
<div class="page-header">
<h1>Dashboard</h1>
</div>
<div class="row">
<div class="col-md-3">
<div class="panel panel-default">
<div class="panel-heading">Disk Storage</div>
<div class="panel-body">
<div id="disk-stats">
<p>Loading...</p>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="panel panel-default">
<div class="panel-heading">Tape Library</div>
<div class="panel-body">
<div id="tape-stats">
<p>Loading...</p>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="panel panel-default">
<div class="panel-heading">iSCSI</div>
<div class="panel-body">
<div id="iscsi-stats">
<p>Loading...</p>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="panel panel-default">
<div class="panel-heading">Bacula SD</div>
<div class="panel-body">
<div id="bacula-stats">
<p>Loading...</p>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
<div class="panel panel-default">
<div class="panel-heading">Alerts</div>
<div class="panel-body">
<div id="alerts">
<p>No alerts</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="storage" class="tab-content" style="display: none;">
<div class="page-header">
<h1>Storage Repositories</h1>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<button class="btn btn-primary" id="create-repo-btn">Create Repository</button>
</div>
<div class="panel-body">
<table class="table table-striped">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Size</th>
<th>Used</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="repositories-table">
<tr><td colspan="6">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div id="tape" class="tab-content" style="display: none;">
<div class="page-header">
<h1>Tape Library Management</h1>
</div>
<div class="row">
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
Library Status
<button class="btn btn-sm btn-default pull-right" id="inventory-btn">Run Inventory</button>
</div>
<div class="panel-body" id="library-status">
<p>Loading...</p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">Tape Drives</div>
<div class="panel-body" id="drives-list">
<p>Loading...</p>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
<div class="panel panel-default">
<div class="panel-heading">Slots</div>
<div class="panel-body" id="slots-list">
<p>Loading...</p>
</div>
</div>
</div>
</div>
</div>
<div id="iscsi" class="tab-content" style="display: none;">
<div class="page-header">
<h1>iSCSI Target Management</h1>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<button class="btn btn-primary" id="create-target-btn">Create Target</button>
</div>
<div class="panel-body">
<table class="table table-striped">
<thead>
<tr>
<th>IQN</th>
<th>Portals</th>
<th>Initiators</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="targets-table">
<tr><td colspan="5">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">Active Sessions</div>
<div class="panel-body">
<table class="table table-striped">
<thead>
<tr>
<th>Target IQN</th>
<th>Initiator IQN</th>
<th>IP Address</th>
<th>State</th>
</tr>
</thead>
<tbody id="sessions-table">
<tr><td colspan="4">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div id="bacula" class="tab-content" style="display: none;">
<div class="page-header">
<h1>Bacula Integration</h1>
</div>
<div class="row">
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">Storage Daemon Status</div>
<div class="panel-body" id="bacula-status">
<p>Loading...</p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">Actions</div>
<div class="panel-body">
<button class="btn btn-default" id="bacula-inventory-btn">Run Inventory</button>
<button class="btn btn-default" id="bacula-restart-btn">Restart SD</button>
<button class="btn btn-default" id="bacula-config-btn">Generate Config</button>
</div>
</div>
</div>
</div>
</div>
<div id="logs" class="tab-content" style="display: none;">
<div class="page-header">
<h1>Logs & Diagnostics</h1>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<select id="log-service" class="form-control" style="display: inline-block; width: 200px;">
<option value="bams">BAMS</option>
<option value="scst">SCST</option>
<option value="iscsi">iSCSI</option>
<option value="bacula">Bacula</option>
</select>
<button class="btn btn-default" id="refresh-logs-btn">Refresh</button>
<button class="btn btn-default" id="download-bundle-btn">Download Support Bundle</button>
</div>
<div class="panel-body">
<pre id="logs-content" style="max-height: 600px; overflow-y: auto;"></pre>
</div>
</div>
</div>
</div>
<script src="bams.js"></script>
</body>
</html>

19
cockpit/manifest.json Normal file
View File

@@ -0,0 +1,19 @@
{
"version": "1.0.0",
"name": "BAMS",
"displayName": "Backup Appliance Management System",
"description": "Manage disk repositories, tape libraries, iSCSI targets, and Bacula integration",
"author": "BAMS Team",
"license": "GPL-3.0",
"url": "https://github.com/bams/bams",
"tools": {
"bams": {
"label": "BAMS",
"path": "index.html"
}
},
"requires": {
"cockpit": ">= 300"
}
}

32
configs/bams.service Normal file
View File

@@ -0,0 +1,32 @@
[Unit]
Description=BAMS Backend Service
Documentation=https://github.com/bams/bams
After=network.target
[Service]
Type=simple
User=bams
Group=bams
WorkingDirectory=/var/lib/bams
ExecStart=/usr/local/bin/bams-backend
Restart=on-failure
RestartSec=5s
StandardOutput=journal
StandardError=journal
SyslogIdentifier=bams
# Security settings
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/bams /etc/bams
# Environment
Environment="BAMS_CONFIG=/etc/bams/config.yaml"
Environment="BAMS_PORT=8080"
Environment="BAMS_LOG_LEVEL=info"
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,19 @@
# BAMS Configuration File
# Copy to /etc/bams/config.yaml and customize
port: 8080
log_level: info
data_dir: /var/lib/bams
# SCST configuration file path
scst_config: /etc/scst.conf
# Bacula Storage Daemon configuration path
bacula_config: /etc/bacula/bacula-sd.conf
# Security settings
security:
require_https: false
allowed_users: []
# Example: allowed_users: ["admin", "operator"]

20
configs/polkit.rules Normal file
View File

@@ -0,0 +1,20 @@
// Polkit rules for BAMS privileged operations
// Place in /etc/polkit-1/rules.d/50-bams.rules
polkit.addRule(function(action, subject) {
if (action.id == "com.bams.disk.create" ||
action.id == "com.bams.disk.delete" ||
action.id == "com.bams.iscsi.modify" ||
action.id == "com.bams.bacula.restart") {
if (subject.isInGroup("bams-admin")) {
return polkit.Result.YES;
}
}
if (action.id == "com.bams.tape.operate" ||
action.id == "com.bams.bacula.inventory") {
if (subject.isInGroup("bams-admin") || subject.isInGroup("bams-operator")) {
return polkit.Result.YES;
}
}
});