diff --git a/.env b/.env
index ad3e0e5..0e7617f 100644
--- a/.env
+++ b/.env
@@ -3,7 +3,7 @@
# ===========================================
# S3 Configuration (Server)
-S3_ENABLED=false
+S3_ENABLED=true
S3_ENDPOINT=localhost:9000
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
@@ -12,8 +12,9 @@ S3_USE_SSL=false
# Local ZFS Configuration (Server)
ZFS_BASE_DATASET=backup
-CONFIG_FILE=clients.json
-METADATA_FILE=metadata.json
+
+# Database Configuration (Server)
+DATABASE_PATH=zfs-backup.db
# Server Configuration
PORT=8080
@@ -23,9 +24,8 @@ PORT=8080
# ===========================================
CLIENT_ID=client1
# NOTE: Use the RAW API key here, not the hashed version!
-# The server stores the hash in clients.json, client sends raw key
+# The server stores the hash in the database, client sends raw key
API_KEY=secret123
SERVER_URL=http://localhost:8080
LOCAL_DATASET=volume/test
COMPRESS=true
-STORAGE_TYPE=local
diff --git a/clients.json b/clients.json
deleted file mode 100644
index 950271d..0000000
--- a/clients.json
+++ /dev/null
@@ -1,16 +0,0 @@
-[
- {
- "client_id": "client1",
- "api_key": "fcf730b6d95236ecd3c9fc2d92d7b6b2bb061514961aec041d6c7a7192f592e4",
- "max_size_bytes": 107374182400,
- "dataset": "zfs/client1",
- "enabled": true,
- "storage_type": "local",
- "rotation_policy": {
- "keep_hourly": 24,
- "keep_daily": 7,
- "keep_weekly": 4,
- "keep_monthly": 12
- }
- }
-]
\ No newline at end of file
diff --git a/cmd/zfs-server/main.go b/cmd/zfs-server/main.go
index 0fd7eb0..f391ff8 100644
--- a/cmd/zfs-server/main.go
+++ b/cmd/zfs-server/main.go
@@ -5,7 +5,6 @@ package main
import (
"log"
"net/http"
- "os"
"time"
"git.ma-al.com/goc_marek/zfs/internal/server"
@@ -31,23 +30,12 @@ func main() {
localBackend := server.NewLocalBackend(cfg.BaseDataset)
- // Create metadata directory if needed (only if path contains a directory)
- if idx := len(cfg.MetadataFile) - 1; idx > 0 {
- dir := cfg.MetadataFile
- foundSlash := false
- for i := len(dir) - 1; i >= 0; i-- {
- if dir[i] == '/' {
- dir = dir[:i]
- foundSlash = true
- break
- }
- }
- if foundSlash && dir != "" {
- os.MkdirAll(dir, 0755)
- }
+ // Initialize server with SQLite database
+ srv, err := server.New(cfg.DatabasePath, s3Backend, localBackend)
+ if err != nil {
+ log.Fatalf("Failed to initialize server: %v", err)
}
-
- srv := server.New(cfg.ConfigFile, cfg.MetadataFile, s3Backend, localBackend)
+ defer srv.Close()
// Register HTTP routes
mux := http.NewServeMux()
@@ -63,8 +51,7 @@ func main() {
}
log.Printf("ZFS Snapshot Server starting on port %s", cfg.Port)
- log.Printf("Config file: %s", cfg.ConfigFile)
- log.Printf("Metadata file: %s", cfg.MetadataFile)
+ log.Printf("Database: %s", cfg.DatabasePath)
log.Printf("S3 enabled: %v", cfg.S3Enabled)
if err := httpServer.ListenAndServe(); err != nil {
diff --git a/go.mod b/go.mod
index 2256b8d..3841f24 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,7 @@ go 1.25.6
require (
github.com/minio/minio-go/v7 v7.0.98
github.com/mistifyio/go-zfs v2.1.1+incompatible
+ modernc.org/sqlite v1.45.0
)
require (
@@ -15,16 +16,23 @@ require (
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
+ github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/tinylib/msgp v1.6.1 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.46.0 // indirect
+ golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+ modernc.org/libc v1.67.6 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.11.0 // indirect
)
diff --git a/go.sum b/go.sum
index 8888f1b..36ed2be 100644
--- a/go.sum
+++ b/go.sum
@@ -4,8 +4,12 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
@@ -13,6 +17,8 @@ github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4O
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
@@ -21,10 +27,14 @@ github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRi
github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM=
github.com/mistifyio/go-zfs v2.1.1+incompatible h1:gAMO1HM9xBRONLHHYnu5iFsOJUiJdNZo6oqSENd4eW8=
github.com/mistifyio/go-zfs v2.1.1+incompatible/go.mod h1:8AuVvqP/mXw1px98n46wfvcGfQ4ci2FwoAjKYxuo3Z4=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
@@ -35,13 +45,50 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
+golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
+golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
+golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
+golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
+golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
+golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
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=
+modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
+modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
+modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
+modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
+modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
+modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
+modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
+modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
+modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.45.0 h1:r51cSGzKpbptxnby+EIIz5fop4VuE4qFoVEjNvWoObs=
+modernc.org/sqlite v1.45.0/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/internal/client/config.go b/internal/client/config.go
index aa74a33..c6776a7 100644
--- a/internal/client/config.go
+++ b/internal/client/config.go
@@ -9,6 +9,7 @@ import (
)
// Config holds client-side configuration for connecting to the backup server.
+// Note: Storage type is determined by the server, not the client.
type Config struct {
// ClientID is the unique identifier for this client
ClientID string `json:"client_id"`
@@ -20,8 +21,6 @@ type Config struct {
LocalDataset string `json:"local_dataset"`
// Compress enables gzip compression for transfers
Compress bool `json:"compress"`
- // StorageType specifies the storage backend ("s3" or "local")
- StorageType string `json:"storage_type"`
}
// LoadConfig loads client configuration from environment variables and .env file.
@@ -36,7 +35,6 @@ func LoadConfig() *Config {
ServerURL: getEnv("SERVER_URL", "http://backup-server:8080"),
LocalDataset: getEnv("LOCAL_DATASET", "tank/data"),
Compress: getEnv("COMPRESS", "true") == "true",
- StorageType: getEnv("STORAGE_TYPE", "s3"),
}
}
diff --git a/internal/server/admin.go b/internal/server/admin.go
new file mode 100644
index 0000000..1c989ec
--- /dev/null
+++ b/internal/server/admin.go
@@ -0,0 +1,1212 @@
+package server
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "time"
+)
+
+// Admin authentication and management handlers
+
+// generateToken generates a secure random token
+func generateToken() (string, error) {
+ bytes := make([]byte, 32)
+ if _, err := rand.Read(bytes); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(bytes), nil
+}
+
+// AdminLoginRequest represents a login request
+type AdminLoginRequest struct {
+ Username string `json:"username"`
+ Password string `json:"password"`
+}
+
+// AdminLoginResponse represents a login response
+type AdminLoginResponse struct {
+ Success bool `json:"success"`
+ Token string `json:"token,omitempty"`
+ Message string `json:"message,omitempty"`
+}
+
+// handleAdminLogin handles admin login
+func (s *Server) handleAdminLogin(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ var req AdminLoginRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ admin, err := s.db.GetAdminByUsername(req.Username)
+ if err != nil {
+ http.Error(w, "Database error", http.StatusInternalServerError)
+ return
+ }
+
+ if admin == nil {
+ json.NewEncoder(w).Encode(AdminLoginResponse{
+ Success: false,
+ Message: "Invalid credentials",
+ })
+ return
+ }
+
+ // Verify password
+ if admin.PasswordHash != hashAPIKey(req.Password) {
+ json.NewEncoder(w).Encode(AdminLoginResponse{
+ Success: false,
+ Message: "Invalid credentials",
+ })
+ return
+ }
+
+ // Generate session token
+ token, err := generateToken()
+ if err != nil {
+ http.Error(w, "Failed to generate token", http.StatusInternalServerError)
+ return
+ }
+
+ // Create session (valid for 24 hours)
+ expiresAt := time.Now().Add(24 * time.Hour)
+ if err := s.db.CreateSession(admin.ID, token, expiresAt); err != nil {
+ http.Error(w, "Failed to create session", http.StatusInternalServerError)
+ return
+ }
+
+ // Set cookie
+ http.SetCookie(w, &http.Cookie{
+ Name: "admin_token",
+ Value: token,
+ Path: "/",
+ Expires: expiresAt,
+ HttpOnly: true,
+ SameSite: http.SameSiteStrictMode,
+ })
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(AdminLoginResponse{
+ Success: true,
+ Token: token,
+ Message: "Login successful",
+ })
+}
+
+// handleAdminLogout handles admin logout
+func (s *Server) handleAdminLogout(w http.ResponseWriter, r *http.Request) {
+ cookie, err := r.Cookie("admin_token")
+ if err == nil {
+ s.db.DeleteSession(cookie.Value)
+ }
+
+ http.SetCookie(w, &http.Cookie{
+ Name: "admin_token",
+ Value: "",
+ Path: "/",
+ MaxAge: -1,
+ HttpOnly: true,
+ })
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "success": true,
+ "message": "Logged out successfully",
+ })
+}
+
+// authenticateAdmin checks if the request has a valid admin session
+func (s *Server) authenticateAdmin(r *http.Request) (*Admin, error) {
+ cookie, err := r.Cookie("admin_token")
+ if err != nil {
+ return nil, err
+ }
+
+ session, err := s.db.GetSessionByToken(cookie.Value)
+ if err != nil {
+ return nil, err
+ }
+
+ if session == nil {
+ return nil, nil
+ }
+
+ if session.ExpiresAt.Before(time.Now()) {
+ s.db.DeleteSession(cookie.Value)
+ return nil, nil
+ }
+
+ admin, err := s.db.GetAdminByID(session.AdminID)
+ if err != nil {
+ return nil, err
+ }
+
+ return admin, nil
+}
+
+// handleAdminCheck checks if admin is authenticated
+func (s *Server) handleAdminCheck(w http.ResponseWriter, r *http.Request) {
+ admin, err := s.authenticateAdmin(r)
+ if err != nil || admin == nil {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "authenticated": false,
+ })
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "authenticated": true,
+ "username": admin.Username,
+ "role": admin.Role,
+ })
+}
+
+// Client management handlers
+
+// handleAdminGetClients returns all clients
+func (s *Server) handleAdminGetClients(w http.ResponseWriter, r *http.Request) {
+ admin, err := s.authenticateAdmin(r)
+ if err != nil || admin == nil {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ clients, err := s.db.GetAllClients()
+ if err != nil {
+ http.Error(w, "Database error", http.StatusInternalServerError)
+ return
+ }
+
+ // Add usage info for each client
+ type ClientWithUsage struct {
+ *ClientConfig
+ CurrentUsage int64 `json:"current_usage"`
+ SnapshotCount int `json:"snapshot_count"`
+ }
+
+ var result []ClientWithUsage
+ for _, c := range clients {
+ usage, _ := s.db.GetClientUsage(c.ClientID)
+ snapshots, _ := s.db.GetSnapshotsByClient(c.ClientID)
+ result = append(result, ClientWithUsage{
+ ClientConfig: c,
+ CurrentUsage: usage,
+ SnapshotCount: len(snapshots),
+ })
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(result)
+}
+
+// handleAdminGetClient returns a specific client
+func (s *Server) handleAdminGetClient(w http.ResponseWriter, r *http.Request) {
+ admin, err := s.authenticateAdmin(r)
+ if err != nil || admin == nil {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ clientID := r.URL.Query().Get("client_id")
+ if clientID == "" {
+ http.Error(w, "client_id required", http.StatusBadRequest)
+ return
+ }
+
+ client, err := s.db.GetClient(clientID)
+ if err != nil {
+ http.Error(w, "Database error", http.StatusInternalServerError)
+ return
+ }
+
+ if client == nil {
+ http.Error(w, "Client not found", http.StatusNotFound)
+ return
+ }
+
+ usage, _ := s.db.GetClientUsage(clientID)
+ snapshots, _ := s.db.GetSnapshotsByClient(clientID)
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "client": client,
+ "current_usage": usage,
+ "snapshot_count": len(snapshots),
+ })
+}
+
+// handleAdminCreateClient creates a new client
+func (s *Server) handleAdminCreateClient(w http.ResponseWriter, r *http.Request) {
+ admin, err := s.authenticateAdmin(r)
+ if err != nil || admin == nil {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ var req struct {
+ ClientID string `json:"client_id"`
+ APIKey string `json:"api_key"`
+ MaxSizeBytes int64 `json:"max_size_bytes"`
+ Dataset string `json:"dataset"`
+ StorageType string `json:"storage_type"`
+ Enabled bool `json:"enabled"`
+ RotationPolicy *RotationPolicy `json:"rotation_policy"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.ClientID == "" || req.APIKey == "" {
+ http.Error(w, "client_id and api_key required", http.StatusBadRequest)
+ return
+ }
+
+ if req.StorageType == "" {
+ req.StorageType = "s3"
+ }
+
+ client := &ClientConfig{
+ ClientID: req.ClientID,
+ APIKey: hashAPIKey(req.APIKey),
+ MaxSizeBytes: req.MaxSizeBytes,
+ Dataset: req.Dataset,
+ StorageType: req.StorageType,
+ Enabled: req.Enabled,
+ RotationPolicy: req.RotationPolicy,
+ }
+
+ if err := s.db.SaveClient(client); err != nil {
+ http.Error(w, "Failed to create client", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "success": true,
+ "message": "Client created successfully",
+ })
+}
+
+// handleAdminUpdateClient updates an existing client
+func (s *Server) handleAdminUpdateClient(w http.ResponseWriter, r *http.Request) {
+ admin, err := s.authenticateAdmin(r)
+ if err != nil || admin == nil {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ if r.Method != http.MethodPut && r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ var req struct {
+ ClientID string `json:"client_id"`
+ APIKey string `json:"api_key"`
+ MaxSizeBytes int64 `json:"max_size_bytes"`
+ Dataset string `json:"dataset"`
+ StorageType string `json:"storage_type"`
+ Enabled bool `json:"enabled"`
+ RotationPolicy *RotationPolicy `json:"rotation_policy"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.ClientID == "" {
+ http.Error(w, "client_id required", http.StatusBadRequest)
+ return
+ }
+
+ // Get existing client
+ existing, err := s.db.GetClient(req.ClientID)
+ if err != nil {
+ http.Error(w, "Database error", http.StatusInternalServerError)
+ return
+ }
+
+ if existing == nil {
+ http.Error(w, "Client not found", http.StatusNotFound)
+ return
+ }
+
+ // Update fields
+ if req.APIKey != "" {
+ existing.APIKey = hashAPIKey(req.APIKey)
+ }
+ if req.MaxSizeBytes > 0 {
+ existing.MaxSizeBytes = req.MaxSizeBytes
+ }
+ if req.Dataset != "" {
+ existing.Dataset = req.Dataset
+ }
+ if req.StorageType != "" {
+ existing.StorageType = req.StorageType
+ }
+ existing.Enabled = req.Enabled
+ if req.RotationPolicy != nil {
+ existing.RotationPolicy = req.RotationPolicy
+ }
+
+ if err := s.db.SaveClient(existing); err != nil {
+ http.Error(w, "Failed to update client", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "success": true,
+ "message": "Client updated successfully",
+ })
+}
+
+// handleAdminDeleteClient deletes a client
+func (s *Server) handleAdminDeleteClient(w http.ResponseWriter, r *http.Request) {
+ admin, err := s.authenticateAdmin(r)
+ if err != nil || admin == nil {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ if r.Method != http.MethodDelete && r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ clientID := r.URL.Query().Get("client_id")
+ if clientID == "" {
+ http.Error(w, "client_id required", http.StatusBadRequest)
+ return
+ }
+
+ // Delete snapshots first (handled by foreign key cascade)
+ // Get snapshots to delete from storage
+ snapshots, _ := s.db.GetSnapshotsByClient(clientID)
+ for _, snap := range snapshots {
+ if snap.StorageType == "s3" && s.s3Backend != nil {
+ s.s3Backend.Delete(context.Background(), snap.StorageKey)
+ }
+ }
+
+ if err := s.db.DeleteClient(clientID); err != nil {
+ http.Error(w, "Failed to delete client", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "success": true,
+ "message": "Client deleted successfully",
+ })
+}
+
+// handleAdminGetSnapshots returns all snapshots for a client
+func (s *Server) handleAdminGetSnapshots(w http.ResponseWriter, r *http.Request) {
+ admin, err := s.authenticateAdmin(r)
+ if err != nil || admin == nil {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ clientID := r.URL.Query().Get("client_id")
+ var snapshots []*SnapshotMetadata
+
+ if clientID != "" {
+ snapshots, err = s.db.GetSnapshotsByClient(clientID)
+ } else {
+ snapshots, err = s.db.GetAllSnapshots()
+ }
+
+ if err != nil {
+ http.Error(w, "Database error", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(snapshots)
+}
+
+// handleAdminDeleteSnapshot deletes a specific snapshot
+func (s *Server) handleAdminDeleteSnapshot(w http.ResponseWriter, r *http.Request) {
+ admin, err := s.authenticateAdmin(r)
+ if err != nil || admin == nil {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ if r.Method != http.MethodDelete && r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ clientID := r.URL.Query().Get("client_id")
+ snapshotID := r.URL.Query().Get("snapshot_id")
+
+ if clientID == "" || snapshotID == "" {
+ http.Error(w, "client_id and snapshot_id required", http.StatusBadRequest)
+ return
+ }
+
+ // Get snapshot to delete from storage
+ snap, err := s.db.GetSnapshotByID(clientID, snapshotID)
+ if err != nil {
+ http.Error(w, "Database error", http.StatusInternalServerError)
+ return
+ }
+
+ if snap != nil {
+ if snap.StorageType == "s3" && s.s3Backend != nil {
+ s.s3Backend.Delete(context.Background(), snap.StorageKey)
+ }
+ }
+
+ if err := s.db.DeleteSnapshot(clientID, snapshotID); err != nil {
+ http.Error(w, "Failed to delete snapshot", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "success": true,
+ "message": "Snapshot deleted successfully",
+ })
+}
+
+// handleAdminGetStats returns server statistics
+func (s *Server) handleAdminGetStats(w http.ResponseWriter, r *http.Request) {
+ admin, err := s.authenticateAdmin(r)
+ if err != nil || admin == nil {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ clients, _ := s.db.GetAllClients()
+ totalSnapshots, _ := s.db.GetTotalSnapshotCount()
+ totalStorage, _ := s.db.GetTotalStorageUsed()
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "client_count": len(clients),
+ "total_snapshots": totalSnapshots,
+ "total_storage": totalStorage,
+ "total_storage_gb": float64(totalStorage) / (1024 * 1024 * 1024),
+ })
+}
+
+// Admin management handlers
+
+// handleAdminGetAdmins returns all admins
+func (s *Server) handleAdminGetAdmins(w http.ResponseWriter, r *http.Request) {
+ admin, err := s.authenticateAdmin(r)
+ if err != nil || admin == nil {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ admins, err := s.db.GetAllAdmins()
+ if err != nil {
+ http.Error(w, "Database error", http.StatusInternalServerError)
+ return
+ }
+
+ // Remove password hashes from response
+ type AdminResponse struct {
+ ID int `json:"id"`
+ Username string `json:"username"`
+ Role string `json:"role"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ }
+
+ var result []AdminResponse
+ for _, a := range admins {
+ result = append(result, AdminResponse{
+ ID: a.ID,
+ Username: a.Username,
+ Role: a.Role,
+ CreatedAt: a.CreatedAt,
+ UpdatedAt: a.UpdatedAt,
+ })
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(result)
+}
+
+// handleAdminUI serves the admin panel UI
+func (s *Server) handleAdminUI(w http.ResponseWriter, r *http.Request) {
+ // Serve the embedded admin UI HTML
+ w.Header().Set("Content-Type", "text/html")
+ w.Write([]byte(adminPanelHTML))
+}
+
+// adminPanelHTML is the embedded admin panel UI
+const adminPanelHTML = `
+
+
+
+
+ ZFS Backup Admin Panel
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Client ID |
+ Storage Type |
+ Quota |
+ Used |
+ Snapshots |
+ Status |
+ Actions |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Client |
+ Snapshot ID |
+ Timestamp |
+ Size |
+ Type |
+ Actions |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | ID |
+ Username |
+ Role |
+ Created |
+ Actions |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`
+
+func (s *Server) handleAdminCreateAdmin(w http.ResponseWriter, r *http.Request) {
+ admin, err := s.authenticateAdmin(r)
+ if err != nil || admin == nil {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ var req struct {
+ Username string `json:"username"`
+ Password string `json:"password"`
+ Role string `json:"role"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.Username == "" || req.Password == "" {
+ http.Error(w, "username and password required", http.StatusBadRequest)
+ return
+ }
+
+ if req.Role == "" {
+ req.Role = "admin"
+ }
+
+ if err := s.db.CreateAdmin(req.Username, hashAPIKey(req.Password), req.Role); err != nil {
+ http.Error(w, "Failed to create admin (username may already exist)", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "success": true,
+ "message": "Admin created successfully",
+ })
+}
+
+// handleAdminDeleteAdmin deletes an admin
+func (s *Server) handleAdminDeleteAdmin(w http.ResponseWriter, r *http.Request) {
+ admin, err := s.authenticateAdmin(r)
+ if err != nil || admin == nil {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ if r.Method != http.MethodDelete && r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ id := r.URL.Query().Get("id")
+ if id == "" {
+ http.Error(w, "id required", http.StatusBadRequest)
+ return
+ }
+
+ // Prevent deleting yourself
+ if admin != nil {
+ var adminID int
+ fmt.Sscanf(id, "%d", &adminID)
+ if adminID == admin.ID {
+ http.Error(w, "Cannot delete yourself", http.StatusBadRequest)
+ return
+ }
+ }
+
+ if err := s.db.DeleteAdmin(0); err != nil {
+ // Parse id as int
+ var adminID int
+ fmt.Sscanf(id, "%d", &adminID)
+ if err := s.db.DeleteAdmin(adminID); err != nil {
+ http.Error(w, "Failed to delete admin", http.StatusInternalServerError)
+ return
+ }
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "success": true,
+ "message": "Admin deleted successfully",
+ })
+}
diff --git a/internal/server/config.go b/internal/server/config.go
index b7c6ffc..1c462a3 100644
--- a/internal/server/config.go
+++ b/internal/server/config.go
@@ -15,8 +15,7 @@ type Config struct {
S3UseSSL bool
S3Enabled bool // Enable/disable S3 backend
BaseDataset string
- ConfigFile string
- MetadataFile string
+ DatabasePath string // Path to SQLite database
Port string
}
@@ -42,8 +41,7 @@ func LoadConfig() *Config {
S3UseSSL: getEnv("S3_USE_SSL", "true") != "false",
S3Enabled: s3Enabled,
BaseDataset: getEnv("ZFS_BASE_DATASET", "backup"),
- ConfigFile: getEnv("CONFIG_FILE", "clients.json"),
- MetadataFile: getEnv("METADATA_FILE", "metadata.json"),
+ DatabasePath: getEnv("DATABASE_PATH", "zfs-backup.db"),
Port: getEnv("PORT", "8080"),
}
}
diff --git a/internal/server/database.go b/internal/server/database.go
new file mode 100644
index 0000000..45c7d64
--- /dev/null
+++ b/internal/server/database.go
@@ -0,0 +1,654 @@
+package server
+
+import (
+ "database/sql"
+ "fmt"
+ "log"
+ "time"
+
+ _ "modernc.org/sqlite"
+)
+
+// Database handles SQLite operations for the server
+type Database struct {
+ db *sql.DB
+}
+
+// NewDatabase creates a new database connection and initializes tables
+func NewDatabase(dbPath string) (*Database, error) {
+ db, err := sql.Open("sqlite", dbPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to open database: %v", err)
+ }
+
+ // Enable foreign keys
+ if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
+ db.Close()
+ return nil, fmt.Errorf("failed to enable foreign keys: %v", err)
+ }
+
+ database := &Database{db: db}
+ if err := database.initTables(); err != nil {
+ db.Close()
+ return nil, fmt.Errorf("failed to initialize tables: %v", err)
+ }
+
+ return database, nil
+}
+
+// initTables creates the database tables if they don't exist
+func (d *Database) initTables() error {
+ // Admins table
+ _, err := d.db.Exec(`
+ CREATE TABLE IF NOT EXISTS admins (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT UNIQUE NOT NULL,
+ password_hash TEXT NOT NULL,
+ role TEXT NOT NULL DEFAULT 'admin',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )
+ `)
+ if err != nil {
+ return fmt.Errorf("failed to create admins table: %v", err)
+ }
+
+ // Admin sessions table
+ _, err = d.db.Exec(`
+ CREATE TABLE IF NOT EXISTS admin_sessions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ admin_id INTEGER NOT NULL,
+ token TEXT UNIQUE NOT NULL,
+ expires_at DATETIME NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (admin_id) REFERENCES admins(id) ON DELETE CASCADE
+ )
+ `)
+ if err != nil {
+ return fmt.Errorf("failed to create admin_sessions table: %v", err)
+ }
+
+ // Clients table
+ _, err = d.db.Exec(`
+ CREATE TABLE IF NOT EXISTS clients (
+ client_id TEXT PRIMARY KEY,
+ api_key TEXT NOT NULL,
+ max_size_bytes INTEGER NOT NULL DEFAULT 0,
+ dataset TEXT NOT NULL,
+ enabled INTEGER NOT NULL DEFAULT 1,
+ storage_type TEXT NOT NULL DEFAULT 's3',
+ keep_hourly INTEGER DEFAULT 24,
+ keep_daily INTEGER DEFAULT 7,
+ keep_weekly INTEGER DEFAULT 4,
+ keep_monthly INTEGER DEFAULT 12,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )
+ `)
+ if err != nil {
+ return fmt.Errorf("failed to create clients table: %v", err)
+ }
+
+ // Snapshots table
+ _, err = d.db.Exec(`
+ CREATE TABLE IF NOT EXISTS snapshots (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ client_id TEXT NOT NULL,
+ snapshot_id TEXT NOT NULL,
+ timestamp DATETIME NOT NULL,
+ size_bytes INTEGER NOT NULL DEFAULT 0,
+ dataset_name TEXT NOT NULL,
+ storage_key TEXT NOT NULL,
+ storage_type TEXT NOT NULL,
+ compressed INTEGER NOT NULL DEFAULT 0,
+ incremental INTEGER NOT NULL DEFAULT 0,
+ base_snapshot TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (client_id) REFERENCES clients(client_id) ON DELETE CASCADE,
+ UNIQUE(client_id, snapshot_id)
+ )
+ `)
+ if err != nil {
+ return fmt.Errorf("failed to create snapshots table: %v", err)
+ }
+
+ // Create indexes
+ _, err = d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_snapshots_client_id ON snapshots(client_id)`)
+ if err != nil {
+ return fmt.Errorf("failed to create index: %v", err)
+ }
+
+ _, err = d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_snapshots_timestamp ON snapshots(timestamp)`)
+ if err != nil {
+ return fmt.Errorf("failed to create timestamp index: %v", err)
+ }
+
+ _, err = d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_admin_sessions_token ON admin_sessions(token)`)
+ if err != nil {
+ return fmt.Errorf("failed to create admin_sessions index: %v", err)
+ }
+
+ return nil
+}
+
+// Close closes the database connection
+func (d *Database) Close() error {
+ return d.db.Close()
+}
+
+// Client operations
+
+// GetClient retrieves a client by ID
+func (d *Database) GetClient(clientID string) (*ClientConfig, error) {
+ client := &ClientConfig{}
+ var enabled int
+ var keepHourly, keepDaily, keepWeekly, keepMonthly sql.NullInt64
+
+ query := `SELECT client_id, api_key, max_size_bytes, dataset, enabled, storage_type,
+ keep_hourly, keep_daily, keep_weekly, keep_monthly
+ FROM clients WHERE client_id = ?`
+
+ err := d.db.QueryRow(query, clientID).Scan(
+ &client.ClientID, &client.APIKey, &client.MaxSizeBytes, &client.Dataset,
+ &enabled, &client.StorageType,
+ &keepHourly, &keepDaily, &keepWeekly, &keepMonthly,
+ )
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ client.Enabled = enabled == 1
+
+ // Set rotation policy if any values are set
+ if keepHourly.Valid || keepDaily.Valid || keepWeekly.Valid || keepMonthly.Valid {
+ client.RotationPolicy = &RotationPolicy{
+ KeepHourly: int(keepHourly.Int64),
+ KeepDaily: int(keepDaily.Int64),
+ KeepWeekly: int(keepWeekly.Int64),
+ KeepMonthly: int(keepMonthly.Int64),
+ }
+ }
+
+ return client, nil
+}
+
+// GetAllClients retrieves all clients
+func (d *Database) GetAllClients() ([]*ClientConfig, error) {
+ query := `SELECT client_id, api_key, max_size_bytes, dataset, enabled, storage_type,
+ keep_hourly, keep_daily, keep_weekly, keep_monthly
+ FROM clients`
+
+ rows, err := d.db.Query(query)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var clients []*ClientConfig
+ for rows.Next() {
+ client := &ClientConfig{}
+ var enabled int
+ var keepHourly, keepDaily, keepWeekly, keepMonthly sql.NullInt64
+
+ err := rows.Scan(
+ &client.ClientID, &client.APIKey, &client.MaxSizeBytes, &client.Dataset,
+ &enabled, &client.StorageType,
+ &keepHourly, &keepDaily, &keepWeekly, &keepMonthly,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ client.Enabled = enabled == 1
+
+ if keepHourly.Valid || keepDaily.Valid || keepWeekly.Valid || keepMonthly.Valid {
+ client.RotationPolicy = &RotationPolicy{
+ KeepHourly: int(keepHourly.Int64),
+ KeepDaily: int(keepDaily.Int64),
+ KeepWeekly: int(keepWeekly.Int64),
+ KeepMonthly: int(keepMonthly.Int64),
+ }
+ }
+
+ clients = append(clients, client)
+ }
+
+ return clients, nil
+}
+
+// SaveClient saves or updates a client
+func (d *Database) SaveClient(client *ClientConfig) error {
+ var keepHourly, keepDaily, keepWeekly, keepMonthly interface{}
+ if client.RotationPolicy != nil {
+ keepHourly = client.RotationPolicy.KeepHourly
+ keepDaily = client.RotationPolicy.KeepDaily
+ keepWeekly = client.RotationPolicy.KeepWeekly
+ keepMonthly = client.RotationPolicy.KeepMonthly
+ }
+
+ enabled := 0
+ if client.Enabled {
+ enabled = 1
+ }
+
+ query := `INSERT OR REPLACE INTO clients
+ (client_id, api_key, max_size_bytes, dataset, enabled, storage_type,
+ keep_hourly, keep_daily, keep_weekly, keep_monthly, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`
+
+ _, err := d.db.Exec(query,
+ client.ClientID, client.APIKey, client.MaxSizeBytes, client.Dataset,
+ enabled, client.StorageType,
+ keepHourly, keepDaily, keepWeekly, keepMonthly,
+ )
+ return err
+}
+
+// Snapshot operations
+
+// SaveSnapshot saves a new snapshot record
+func (d *Database) SaveSnapshot(metadata *SnapshotMetadata) error {
+ compressed := 0
+ if metadata.Compressed {
+ compressed = 1
+ }
+ incremental := 0
+ if metadata.Incremental {
+ incremental = 1
+ }
+
+ query := `INSERT INTO snapshots
+ (client_id, snapshot_id, timestamp, size_bytes, dataset_name,
+ storage_key, storage_type, compressed, incremental, base_snapshot)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
+
+ _, err := d.db.Exec(query,
+ metadata.ClientID, metadata.SnapshotID, metadata.Timestamp, metadata.SizeBytes,
+ metadata.DatasetName, metadata.StorageKey, metadata.StorageType,
+ compressed, incremental, metadata.BaseSnapshot,
+ )
+ return err
+}
+
+// GetSnapshotsByClient retrieves all snapshots for a client
+func (d *Database) GetSnapshotsByClient(clientID string) ([]*SnapshotMetadata, error) {
+ query := `SELECT client_id, snapshot_id, timestamp, size_bytes, dataset_name,
+ storage_key, storage_type, compressed, incremental, base_snapshot
+ FROM snapshots WHERE client_id = ? ORDER BY timestamp DESC`
+
+ rows, err := d.db.Query(query, clientID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var snapshots []*SnapshotMetadata
+ for rows.Next() {
+ snap := &SnapshotMetadata{}
+ var compressed, incremental int
+ var baseSnapshot sql.NullString
+
+ err := rows.Scan(
+ &snap.ClientID, &snap.SnapshotID, &snap.Timestamp, &snap.SizeBytes,
+ &snap.DatasetName, &snap.StorageKey, &snap.StorageType,
+ &compressed, &incremental, &baseSnapshot,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ snap.Compressed = compressed == 1
+ snap.Incremental = incremental == 1
+ if baseSnapshot.Valid {
+ snap.BaseSnapshot = baseSnapshot.String
+ }
+
+ snapshots = append(snapshots, snap)
+ }
+
+ return snapshots, nil
+}
+
+// GetClientUsage calculates total storage used by a client
+func (d *Database) GetClientUsage(clientID string) (int64, error) {
+ var total sql.NullInt64
+ err := d.db.QueryRow(`SELECT SUM(size_bytes) FROM snapshots WHERE client_id = ?`, clientID).Scan(&total)
+ if err != nil {
+ return 0, err
+ }
+ if !total.Valid {
+ return 0, nil
+ }
+ return total.Int64, nil
+}
+
+// DeleteSnapshot deletes a snapshot record
+func (d *Database) DeleteSnapshot(clientID, snapshotID string) error {
+ _, err := d.db.Exec(`DELETE FROM snapshots WHERE client_id = ? AND snapshot_id = ?`, clientID, snapshotID)
+ return err
+}
+
+// GetOldestSnapshots gets the oldest snapshots for a client (for rotation)
+func (d *Database) GetOldestSnapshots(clientID string, limit int) ([]*SnapshotMetadata, error) {
+ query := `SELECT client_id, snapshot_id, timestamp, size_bytes, dataset_name,
+ storage_key, storage_type, compressed, incremental, base_snapshot
+ FROM snapshots WHERE client_id = ? ORDER BY timestamp ASC LIMIT ?`
+
+ rows, err := d.db.Query(query, clientID, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var snapshots []*SnapshotMetadata
+ for rows.Next() {
+ snap := &SnapshotMetadata{}
+ var compressed, incremental int
+ var baseSnapshot sql.NullString
+
+ err := rows.Scan(
+ &snap.ClientID, &snap.SnapshotID, &snap.Timestamp, &snap.SizeBytes,
+ &snap.DatasetName, &snap.StorageKey, &snap.StorageType,
+ &compressed, &incremental, &baseSnapshot,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ snap.Compressed = compressed == 1
+ snap.Incremental = incremental == 1
+ if baseSnapshot.Valid {
+ snap.BaseSnapshot = baseSnapshot.String
+ }
+
+ snapshots = append(snapshots, snap)
+ }
+
+ return snapshots, nil
+}
+
+// CreateDefaultClient creates a default client if none exists
+func (d *Database) CreateDefaultClient() error {
+ clients, err := d.GetAllClients()
+ if err != nil {
+ return err
+ }
+
+ if len(clients) > 0 {
+ return nil
+ }
+
+ log.Println("No clients found, creating default client 'client1'")
+ defaultClient := &ClientConfig{
+ ClientID: "client1",
+ APIKey: hashAPIKey("secret123"),
+ MaxSizeBytes: 100 * 1024 * 1024 * 1024, // 100GB
+ Dataset: "backup/client1",
+ Enabled: true,
+ StorageType: "s3",
+ RotationPolicy: &RotationPolicy{
+ KeepHourly: 24,
+ KeepDaily: 7,
+ KeepWeekly: 4,
+ KeepMonthly: 12,
+ },
+ }
+
+ return d.SaveClient(defaultClient)
+}
+
+// GetSnapshotByID retrieves a specific snapshot
+func (d *Database) GetSnapshotByID(clientID, snapshotID string) (*SnapshotMetadata, error) {
+ snap := &SnapshotMetadata{}
+ var compressed, incremental int
+ var baseSnapshot sql.NullString
+
+ query := `SELECT client_id, snapshot_id, timestamp, size_bytes, dataset_name,
+ storage_key, storage_type, compressed, incremental, base_snapshot
+ FROM snapshots WHERE client_id = ? AND snapshot_id = ?`
+
+ err := d.db.QueryRow(query, clientID, snapshotID).Scan(
+ &snap.ClientID, &snap.SnapshotID, &snap.Timestamp, &snap.SizeBytes,
+ &snap.DatasetName, &snap.StorageKey, &snap.StorageType,
+ &compressed, &incremental, &baseSnapshot,
+ )
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ snap.Compressed = compressed == 1
+ snap.Incremental = incremental == 1
+ if baseSnapshot.Valid {
+ snap.BaseSnapshot = baseSnapshot.String
+ }
+
+ return snap, nil
+}
+
+// GetAllSnapshots retrieves all snapshots (for admin purposes)
+func (d *Database) GetAllSnapshots() ([]*SnapshotMetadata, error) {
+ query := `SELECT client_id, snapshot_id, timestamp, size_bytes, dataset_name,
+ storage_key, storage_type, compressed, incremental, base_snapshot
+ FROM snapshots ORDER BY timestamp DESC`
+
+ rows, err := d.db.Query(query)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var snapshots []*SnapshotMetadata
+ for rows.Next() {
+ snap := &SnapshotMetadata{}
+ var compressed, incremental int
+ var baseSnapshot sql.NullString
+
+ err := rows.Scan(
+ &snap.ClientID, &snap.SnapshotID, &snap.Timestamp, &snap.SizeBytes,
+ &snap.DatasetName, &snap.StorageKey, &snap.StorageType,
+ &compressed, &incremental, &baseSnapshot,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ snap.Compressed = compressed == 1
+ snap.Incremental = incremental == 1
+ if baseSnapshot.Valid {
+ snap.BaseSnapshot = baseSnapshot.String
+ }
+
+ snapshots = append(snapshots, snap)
+ }
+
+ return snapshots, nil
+}
+
+// GetTotalSnapshotCount returns the total number of snapshots
+func (d *Database) GetTotalSnapshotCount() (int, error) {
+ var count int
+ err := d.db.QueryRow(`SELECT COUNT(*) FROM snapshots`).Scan(&count)
+ return count, err
+}
+
+// GetTotalStorageUsed returns the total storage used across all clients
+func (d *Database) GetTotalStorageUsed() (int64, error) {
+ var total sql.NullInt64
+ err := d.db.QueryRow(`SELECT SUM(size_bytes) FROM snapshots`).Scan(&total)
+ if err != nil {
+ return 0, err
+ }
+ if !total.Valid {
+ return 0, nil
+ }
+ return total.Int64, nil
+}
+
+// UpdateSnapshotTimestamp updates the timestamp of a snapshot (for rotation tracking)
+func (d *Database) UpdateSnapshotTimestamp(clientID, snapshotID string, timestamp time.Time) error {
+ _, err := d.db.Exec(`UPDATE snapshots SET timestamp = ? WHERE client_id = ? AND snapshot_id = ?`,
+ timestamp, clientID, snapshotID)
+ return err
+}
+
+// Admin operations
+
+// Admin represents an admin user
+type Admin struct {
+ ID int
+ Username string
+ PasswordHash string
+ Role string
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+// AdminSession represents an admin session
+type AdminSession struct {
+ ID int
+ AdminID int
+ Token string
+ ExpiresAt time.Time
+ CreatedAt time.Time
+}
+
+// CreateAdmin creates a new admin user
+func (d *Database) CreateAdmin(username, passwordHash, role string) error {
+ _, err := d.db.Exec(`
+ INSERT INTO admins (username, password_hash, role) VALUES (?, ?, ?)
+ `, username, passwordHash, role)
+ return err
+}
+
+// GetAdminByUsername retrieves an admin by username
+func (d *Database) GetAdminByUsername(username string) (*Admin, error) {
+ admin := &Admin{}
+ err := d.db.QueryRow(`
+ SELECT id, username, password_hash, role, created_at, updated_at
+ FROM admins WHERE username = ?
+ `, username).Scan(&admin.ID, &admin.Username, &admin.PasswordHash, &admin.Role, &admin.CreatedAt, &admin.UpdatedAt)
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return admin, nil
+}
+
+// GetAdminByID retrieves an admin by ID
+func (d *Database) GetAdminByID(id int) (*Admin, error) {
+ admin := &Admin{}
+ err := d.db.QueryRow(`
+ SELECT id, username, password_hash, role, created_at, updated_at
+ FROM admins WHERE id = ?
+ `, id).Scan(&admin.ID, &admin.Username, &admin.PasswordHash, &admin.Role, &admin.CreatedAt, &admin.UpdatedAt)
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return admin, nil
+}
+
+// CreateSession creates a new admin session
+func (d *Database) CreateSession(adminID int, token string, expiresAt time.Time) error {
+ _, err := d.db.Exec(`
+ INSERT INTO admin_sessions (admin_id, token, expires_at) VALUES (?, ?, ?)
+ `, adminID, token, expiresAt)
+ return err
+}
+
+// GetSessionByToken retrieves a session by token
+func (d *Database) GetSessionByToken(token string) (*AdminSession, error) {
+ session := &AdminSession{}
+ err := d.db.QueryRow(`
+ SELECT id, admin_id, token, expires_at, created_at
+ FROM admin_sessions WHERE token = ?
+ `, token).Scan(&session.ID, &session.AdminID, &session.Token, &session.ExpiresAt, &session.CreatedAt)
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return session, nil
+}
+
+// DeleteSession deletes a session (logout)
+func (d *Database) DeleteSession(token string) error {
+ _, err := d.db.Exec(`DELETE FROM admin_sessions WHERE token = ?`, token)
+ return err
+}
+
+// CleanExpiredSessions removes all expired sessions
+func (d *Database) CleanExpiredSessions() error {
+ _, err := d.db.Exec(`DELETE FROM admin_sessions WHERE expires_at < CURRENT_TIMESTAMP`)
+ return err
+}
+
+// CreateDefaultAdmin creates a default admin if none exists
+func (d *Database) CreateDefaultAdmin() error {
+ var count int
+ err := d.db.QueryRow(`SELECT COUNT(*) FROM admins`).Scan(&count)
+ if err != nil {
+ return err
+ }
+
+ if count > 0 {
+ return nil
+ }
+
+ log.Println("No admins found, creating default admin 'admin'")
+ // Default password: admin123
+ defaultPasswordHash := hashAPIKey("admin123")
+ return d.CreateAdmin("admin", defaultPasswordHash, "admin")
+}
+
+// DeleteClient deletes a client and all its snapshots
+func (d *Database) DeleteClient(clientID string) error {
+ _, err := d.db.Exec(`DELETE FROM clients WHERE client_id = ?`, clientID)
+ return err
+}
+
+// GetAllAdmins retrieves all admins
+func (d *Database) GetAllAdmins() ([]*Admin, error) {
+ rows, err := d.db.Query(`
+ SELECT id, username, password_hash, role, created_at, updated_at
+ FROM admins ORDER BY id
+ `)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var admins []*Admin
+ for rows.Next() {
+ admin := &Admin{}
+ err := rows.Scan(&admin.ID, &admin.Username, &admin.PasswordHash, &admin.Role, &admin.CreatedAt, &admin.UpdatedAt)
+ if err != nil {
+ return nil, err
+ }
+ admins = append(admins, admin)
+ }
+ return admins, nil
+}
+
+// DeleteAdmin deletes an admin by ID
+func (d *Database) DeleteAdmin(id int) error {
+ _, err := d.db.Exec(`DELETE FROM admins WHERE id = ?`, id)
+ return err
+}
+
+// UpdateAdminPassword updates an admin's password
+func (d *Database) UpdateAdminPassword(id int, passwordHash string) error {
+ _, err := d.db.Exec(`UPDATE admins SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, passwordHash, id)
+ return err
+}
diff --git a/internal/server/server.go b/internal/server/server.go
index 9f10600..edfe045 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -9,131 +9,60 @@ import (
"io"
"log"
"net/http"
- "os"
- "sort"
"strings"
- "sync"
"time"
)
// Server manages snapshots from multiple clients with S3 support
type Server struct {
- clients map[string]*ClientConfig
- snapshots map[string][]*SnapshotMetadata
- mu sync.RWMutex
+ db *Database
s3Backend *S3Backend
localBackend *LocalBackend
- metadataFile string
- configFile string
}
-// New creates a new snapshot server
-func New(configFile, metadataFile string, s3Backend *S3Backend, localBackend *LocalBackend) *Server {
+// New creates a new snapshot server with SQLite database
+func New(dbPath string, s3Backend *S3Backend, localBackend *LocalBackend) (*Server, error) {
+ db, err := NewDatabase(dbPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to initialize database: %v", err)
+ }
+
+ // Create default client if none exists
+ if err := db.CreateDefaultClient(); err != nil {
+ db.Close()
+ return nil, fmt.Errorf("failed to create default client: %v", err)
+ }
+
+ // Create default admin if none exists
+ if err := db.CreateDefaultAdmin(); err != nil {
+ db.Close()
+ return nil, fmt.Errorf("failed to create default admin: %v", err)
+ }
+
+ // Clean expired sessions
+ db.CleanExpiredSessions()
+
s := &Server{
- clients: make(map[string]*ClientConfig),
- snapshots: make(map[string][]*SnapshotMetadata),
+ db: db,
s3Backend: s3Backend,
localBackend: localBackend,
- metadataFile: metadataFile,
- configFile: configFile,
}
- s.loadConfig()
- s.loadMetadata()
-
- return s
+ return s, nil
}
-func (s *Server) loadConfig() {
- data, err := os.ReadFile(s.configFile)
- if err != nil {
- log.Printf("Warning: Could not read config file: %v", err)
- // Create default config
- s.clients["client1"] = &ClientConfig{
- ClientID: "client1",
- APIKey: hashAPIKey("secret123"),
- MaxSizeBytes: 100 * 1024 * 1024 * 1024,
- Dataset: "backup/client1",
- Enabled: true,
- StorageType: "s3",
- }
- s.saveConfig()
- return
- }
-
- var clients []*ClientConfig
- if err := json.Unmarshal(data, &clients); err != nil {
- log.Printf("Error parsing config: %v", err)
- return
- }
-
- for _, client := range clients {
- s.clients[client.ClientID] = client
- }
-
- log.Printf("Loaded %d client configurations", len(s.clients))
-}
-
-func (s *Server) saveConfig() {
- s.mu.RLock()
- defer s.mu.RUnlock()
-
- var clients []*ClientConfig
- for _, client := range s.clients {
- clients = append(clients, client)
- }
-
- data, err := json.MarshalIndent(clients, "", " ")
- if err != nil {
- log.Printf("Error marshaling config: %v", err)
- return
- }
-
- if err := os.WriteFile(s.configFile, data, 0600); err != nil {
- log.Printf("Error writing config: %v", err)
- }
-}
-
-func (s *Server) loadMetadata() {
- data, err := os.ReadFile(s.metadataFile)
- if err != nil {
- log.Printf("No existing metadata file, starting fresh")
- return
- }
-
- if err := json.Unmarshal(data, &s.snapshots); err != nil {
- log.Printf("Error parsing metadata: %v", err)
- return
- }
-
- totalSnapshots := 0
- for _, snaps := range s.snapshots {
- totalSnapshots += len(snaps)
- }
- log.Printf("Loaded metadata for %d snapshots", totalSnapshots)
-}
-
-func (s *Server) saveMetadata() {
- s.mu.RLock()
- defer s.mu.RUnlock()
-
- data, err := json.MarshalIndent(s.snapshots, "", " ")
- if err != nil {
- log.Printf("Error marshaling metadata: %v", err)
- return
- }
-
- if err := os.WriteFile(s.metadataFile, data, 0600); err != nil {
- log.Printf("Error writing metadata: %v", err)
- }
+// Close closes the database connection
+func (s *Server) Close() error {
+ return s.db.Close()
}
func (s *Server) authenticate(clientID, apiKey string) bool {
- s.mu.RLock()
- defer s.mu.RUnlock()
+ client, err := s.db.GetClient(clientID)
+ if err != nil || client == nil {
+ return false
+ }
- client, exists := s.clients[clientID]
- if !exists || !client.Enabled {
+ if !client.Enabled {
return false
}
@@ -141,22 +70,13 @@ func (s *Server) authenticate(clientID, apiKey string) bool {
}
func (s *Server) getClientUsage(clientID string) int64 {
- s.mu.RLock()
- defer s.mu.RUnlock()
-
- var total int64
- for _, snap := range s.snapshots[clientID] {
- total += snap.SizeBytes
- }
- return total
+ usage, _ := s.db.GetClientUsage(clientID)
+ return usage
}
func (s *Server) canAcceptSnapshot(clientID string, estimatedSize int64) (bool, string) {
- s.mu.RLock()
- defer s.mu.RUnlock()
-
- client, exists := s.clients[clientID]
- if !exists {
+ client, err := s.db.GetClient(clientID)
+ if err != nil || client == nil {
return false, "Client not found"
}
@@ -171,74 +91,69 @@ func (s *Server) canAcceptSnapshot(clientID string, estimatedSize int64) (bool,
}
func (s *Server) rotateSnapshots(clientID string) (int, int64) {
- // First pass: collect snapshots to delete while holding lock
- s.mu.Lock()
- client, exists := s.clients[clientID]
- if !exists {
- s.mu.Unlock()
+ client, err := s.db.GetClient(clientID)
+ if err != nil || client == nil {
return 0, 0
}
- snapshots := s.snapshots[clientID]
- if len(snapshots) == 0 {
- s.mu.Unlock()
+ currentUsage := s.getClientUsage(clientID)
+ if currentUsage <= client.MaxSizeBytes {
return 0, 0
}
- // Sort by timestamp (oldest first)
- sort.Slice(snapshots, func(i, j int) bool {
- return snapshots[i].Timestamp.Before(snapshots[j].Timestamp)
- })
+ // Calculate how many bytes we need to free
+ bytesToFree := currentUsage - client.MaxSizeBytes
+ var deletedCount int
+ var reclaimedBytes int64
- currentUsage := int64(0)
- for _, snap := range snapshots {
- currentUsage += snap.SizeBytes
+ // Get oldest snapshots and delete until we're under quota
+ snapshots, err := s.db.GetOldestSnapshots(clientID, 100) // Get up to 100 oldest
+ if err != nil {
+ log.Printf("Error getting oldest snapshots: %v", err)
+ return 0, 0
}
- // Collect snapshots to delete
var toDelete []*SnapshotMetadata
- for currentUsage > client.MaxSizeBytes && len(snapshots) > 1 {
- oldest := snapshots[0]
- toDelete = append(toDelete, oldest)
- currentUsage -= oldest.SizeBytes
- snapshots = snapshots[1:]
+ for _, snap := range snapshots {
+ if reclaimedBytes >= bytesToFree {
+ break
+ }
+ toDelete = append(toDelete, snap)
+ reclaimedBytes += snap.SizeBytes
}
- // Update state before I/O
- s.snapshots[clientID] = snapshots
- s.mu.Unlock()
-
if len(toDelete) == 0 {
return 0, 0
}
// Select appropriate backend
var backend StorageBackend
- if client.StorageType == "s3" {
+ if s.s3Backend != nil {
backend = s.s3Backend
- } else {
+ } else if s.localBackend != nil {
backend = s.localBackend
+ } else {
+ log.Printf("No storage backend available for rotation")
+ return 0, 0
}
- // Second pass: delete without holding lock
- deletedCount := 0
- reclaimedBytes := int64(0)
+ // Delete snapshots
ctx := context.Background()
-
for _, snap := range toDelete {
if err := backend.Delete(ctx, snap.StorageKey); err != nil {
log.Printf("Error deleting snapshot %s: %v", snap.StorageKey, err)
continue
}
+ if err := s.db.DeleteSnapshot(clientID, snap.SnapshotID); err != nil {
+ log.Printf("Error deleting snapshot record %s: %v", snap.SnapshotID, err)
+ continue
+ }
+
log.Printf("Rotated out snapshot: %s (freed %d bytes)", snap.StorageKey, snap.SizeBytes)
- reclaimedBytes += snap.SizeBytes
deletedCount++
}
- // Save metadata after deletions
- s.saveMetadata()
-
return deletedCount, reclaimedBytes
}
@@ -283,9 +198,14 @@ func (s *Server) HandleUpload(w http.ResponseWriter, r *http.Request) {
return
}
- s.mu.RLock()
- client := s.clients[req.ClientID]
- s.mu.RUnlock()
+ client, err := s.db.GetClient(req.ClientID)
+ if err != nil || client == nil {
+ respondJSON(w, http.StatusInternalServerError, UploadResponse{
+ Success: false,
+ Message: "Failed to get client configuration",
+ })
+ return
+ }
timestamp := time.Now().Format("2006-01-02_15:04:05")
@@ -379,8 +299,7 @@ func (s *Server) HandleUploadStream(w http.ResponseWriter, r *http.Request) {
actualSize = size
}
- // Save metadata
- s.mu.Lock()
+ // Save metadata to database
metadata := &SnapshotMetadata{
ClientID: clientID,
SnapshotID: storageKey,
@@ -393,10 +312,10 @@ func (s *Server) HandleUploadStream(w http.ResponseWriter, r *http.Request) {
Incremental: incrementalStr == "true",
BaseSnapshot: baseSnapshot,
}
- s.snapshots[clientID] = append(s.snapshots[clientID], metadata)
- s.mu.Unlock()
- s.saveMetadata()
+ if err := s.db.SaveSnapshot(metadata); err != nil {
+ log.Printf("Error saving snapshot metadata: %v", err)
+ }
respondJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
@@ -415,10 +334,17 @@ func (s *Server) HandleStatus(w http.ResponseWriter, r *http.Request) {
return
}
- s.mu.RLock()
- client := s.clients[clientID]
- snapshots := s.snapshots[clientID]
- s.mu.RUnlock()
+ client, err := s.db.GetClient(clientID)
+ if err != nil || client == nil {
+ respondJSON(w, http.StatusInternalServerError, StatusResponse{Success: false})
+ return
+ }
+
+ snapshots, err := s.db.GetSnapshotsByClient(clientID)
+ if err != nil {
+ log.Printf("Error getting snapshots: %v", err)
+ snapshots = []*SnapshotMetadata{}
+ }
usedBytes := s.getClientUsage(clientID)
percentUsed := float64(usedBytes) / float64(client.MaxSizeBytes) * 100
@@ -477,18 +403,14 @@ func (s *Server) HandleDownload(w http.ResponseWriter, r *http.Request) {
}
// Find snapshot metadata
- s.mu.RLock()
- client := s.clients[clientID]
- var targetSnapshot *SnapshotMetadata
- for _, snap := range s.snapshots[clientID] {
- if snap.SnapshotID == snapshotID {
- targetSnapshot = snap
- break
- }
+ client, err := s.db.GetClient(clientID)
+ if err != nil || client == nil {
+ http.Error(w, "Client not found", http.StatusNotFound)
+ return
}
- s.mu.RUnlock()
- if targetSnapshot == nil {
+ targetSnapshot, err := s.db.GetSnapshotByID(clientID, snapshotID)
+ if err != nil || targetSnapshot == nil {
http.Error(w, "Snapshot not found", http.StatusNotFound)
return
}
@@ -496,10 +418,13 @@ func (s *Server) HandleDownload(w http.ResponseWriter, r *http.Request) {
ctx := context.Background()
var backend StorageBackend
- if client.StorageType == "s3" {
+ if client.StorageType == "s3" && s.s3Backend != nil {
backend = s.s3Backend
- } else {
+ } else if s.localBackend != nil {
backend = s.localBackend
+ } else {
+ http.Error(w, "No storage backend available", http.StatusInternalServerError)
+ return
}
// Download from storage
@@ -543,11 +468,8 @@ func (s *Server) HandleRotationPolicy(w http.ResponseWriter, r *http.Request) {
return
}
- s.mu.RLock()
- client, exists := s.clients[clientID]
- s.mu.RUnlock()
-
- if !exists {
+ client, err := s.db.GetClient(clientID)
+ if err != nil || client == nil {
respondJSON(w, http.StatusNotFound, RotationPolicyResponse{
Success: false,
Message: "Client not found",
@@ -577,6 +499,7 @@ func (s *Server) HandleRotationPolicy(w http.ResponseWriter, r *http.Request) {
// RegisterRoutes registers all HTTP routes
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
+ // Client API routes
mux.HandleFunc("/upload", s.HandleUpload)
mux.HandleFunc("/upload-stream/", s.HandleUploadStream)
mux.HandleFunc("/status", s.HandleStatus)
@@ -584,6 +507,25 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/download", s.HandleDownload)
mux.HandleFunc("/health", s.HandleHealth)
mux.HandleFunc("/rotation-policy", s.HandleRotationPolicy)
+
+ // Admin API routes
+ mux.HandleFunc("/admin/login", s.handleAdminLogin)
+ mux.HandleFunc("/admin/logout", s.handleAdminLogout)
+ mux.HandleFunc("/admin/check", s.handleAdminCheck)
+ mux.HandleFunc("/admin/clients", s.handleAdminGetClients)
+ mux.HandleFunc("/admin/client", s.handleAdminGetClient)
+ mux.HandleFunc("/admin/client/create", s.handleAdminCreateClient)
+ mux.HandleFunc("/admin/client/update", s.handleAdminUpdateClient)
+ mux.HandleFunc("/admin/client/delete", s.handleAdminDeleteClient)
+ mux.HandleFunc("/admin/snapshots", s.handleAdminGetSnapshots)
+ mux.HandleFunc("/admin/snapshot/delete", s.handleAdminDeleteSnapshot)
+ mux.HandleFunc("/admin/stats", s.handleAdminGetStats)
+ mux.HandleFunc("/admin/admins", s.handleAdminGetAdmins)
+ mux.HandleFunc("/admin/admin/create", s.handleAdminCreateAdmin)
+ mux.HandleFunc("/admin/admin/delete", s.handleAdminDeleteAdmin)
+
+ // Admin UI (static files served from /admin/)
+ mux.HandleFunc("/admin/", s.handleAdminUI)
}
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
diff --git a/readme.md b/readme.md
index f0e2b86..5c59e3c 100644
--- a/readme.md
+++ b/readme.md
@@ -76,12 +76,15 @@ S3_USE_SSL=true
# Local ZFS fallback
ZFS_BASE_DATASET=backup
+# Database Configuration (SQLite)
+DATABASE_PATH=zfs-backup.db
+
# Server settings
-CONFIG_FILE=clients.json
-METADATA_FILE=metadata.json
PORT=8080
```
+> **Note**: All client configuration and snapshot metadata is stored in a SQLite database (`zfs-backup.db` by default). The server automatically creates a default client (`client1` with API key `secret123`) if no clients exist.
+
### Client Configuration
```env
@@ -90,10 +93,11 @@ API_KEY=secret123
SERVER_URL=http://backup-server:8080
LOCAL_DATASET=tank/data
COMPRESS=true
-STORAGE_TYPE=s3
```
-> **Important**: The `API_KEY` in the client `.env` file must be the **raw (unhashed)** key. The server stores the SHA-256 hash in `clients.json`, and the client sends the raw key which the server then hashes for comparison. For example, if `clients.json` has `api_key: "fcf730b6d95236ecd3c9fc2d92d7b6b2bb061514961aec041d6c7a7192f592e4"` (hash of "secret123"), the client `.env` should have `API_KEY=secret123`.
+> **Important**:
+> - The `API_KEY` in the client `.env` file must be the **raw (unhashed)** key. The server stores the SHA-256 hash in the database.
+> - **Storage type is determined by the server**, not the client. The server decides whether to use S3 or local ZFS storage based on its configuration.
### Restore Tool Configuration
@@ -344,35 +348,55 @@ S3_BUCKET=zfs-backups
S3_USE_SSL=true
```
-## Client Configuration File
+## Database Storage
-The server maintains a `clients.json` file with client configurations:
+The server uses SQLite to store all configuration and metadata in a single database file (`zfs-backup.db` by default). This includes:
-```json
-[
- {
- "client_id": "client1",
- "api_key": "hashed_key",
- "max_size_bytes": 107374182400,
- "dataset": "backup/client1",
- "enabled": true,
- "storage_type": "s3",
- "rotation_policy": {
- "keep_hourly": 24,
- "keep_daily": 7,
- "keep_weekly": 4,
- "keep_monthly": 12
- }
- }
-]
-```
+- **Admin users**: Authentication credentials for the admin panel
+- **Client configurations**: Authentication, quotas, storage type, rotation policies
+- **Snapshot metadata**: Timestamps, sizes, storage keys, incremental relationships
+
+### Database Schema
+
+The database contains four main tables:
+
+**admins table:**
+- `id` - Unique identifier
+- `username` - Admin username (unique)
+- `password_hash` - SHA-256 hashed password
+- `role` - Admin role (default: "admin")
+- `created_at`, `updated_at` - Timestamps
+
+**admin_sessions table:**
+- `id` - Unique identifier
+- `admin_id` - Foreign key to admins table
+- `token` - Session token
+- `expires_at` - Session expiration time
+
+**clients table:**
+- `client_id` - Unique identifier
+- `api_key` - SHA-256 hashed API key
+- `max_size_bytes` - Storage quota
+- `dataset` - Target dataset for local ZFS storage
+- `enabled` - Client status
+- `storage_type` - "s3" or "local"
+- `keep_hourly`, `keep_daily`, `keep_weekly`, `keep_monthly` - Rotation policy
+
+**snapshots table:**
+- `client_id` - Owner of the snapshot
+- `snapshot_id` - Unique identifier
+- `timestamp` - When the snapshot was taken
+- `size_bytes` - Snapshot size
+- `storage_key` - Location in storage
+- `storage_type` - Where it's stored
+- `compressed`, `incremental`, `base_snapshot` - Snapshot properties
### Server-Managed Rotation Policy
-When `rotation_policy` is configured for a client in `clients.json`, the client **must** use this policy and cannot override it. This enables centralized control of snapshot retention policies:
+When a rotation policy is configured for a client in the database, the client **must** use this policy and cannot override it. This enables centralized control of snapshot retention policies:
-- **Server-Managed**: If `rotation_policy` is set, the client fetches the policy from the server and applies it
-- **Client-Autonomous**: If no `rotation_policy` is set, the client uses its default policy
+- **Server-Managed**: If rotation policy is set, the client fetches the policy from the server and applies it
+- **Client-Autonomous**: If no rotation policy is set, the client uses its default policy
The rotation policy fields are:
- `keep_hourly`: Number of hourly snapshots to keep (default: 24)
@@ -403,6 +427,82 @@ Response:
}
```
+## Admin Panel
+
+The server includes a web-based admin panel for managing clients, snapshots, and admin users. Access it at `http://localhost:8080/admin/`.
+
+### Default Admin Credentials
+
+When the server starts for the first time, it creates a default admin user:
+- **Username**: `admin`
+- **Password**: `admin123`
+
+> **Important**: Change the default password immediately after first login!
+
+### Admin Panel Features
+
+- **Dashboard**: View statistics (client count, total snapshots, storage usage)
+- **Client Management**:
+ - Create, view, and delete clients
+ - Configure storage type (S3 or local ZFS)
+ - Set quotas and rotation policies
+ - Enable/disable clients
+- **Snapshot Management**:
+ - View all snapshots across all clients
+ - Filter by client
+ - Delete individual snapshots
+- **Admin User Management**:
+ - Create additional admin users
+ - Delete admin accounts
+
+### Admin API Endpoints
+
+All admin endpoints require authentication via session cookie.
+
+| Endpoint | Method | Description |
+|----------|--------|-------------|
+| `/admin/login` | POST | Login with username/password |
+| `/admin/logout` | POST | Logout current session |
+| `/admin/check` | GET | Check authentication status |
+| `/admin/clients` | GET | List all clients with usage stats |
+| `/admin/client` | GET | Get specific client details |
+| `/admin/client/create` | POST | Create new client |
+| `/admin/client/update` | PUT | Update client configuration |
+| `/admin/client/delete` | POST | Delete client and all snapshots |
+| `/admin/snapshots` | GET | List all snapshots |
+| `/admin/snapshot/delete` | POST | Delete specific snapshot |
+| `/admin/stats` | GET | Get server statistics |
+| `/admin/admins` | GET | List all admin users |
+| `/admin/admin/create` | POST | Create new admin user |
+| `/admin/admin/delete` | POST | Delete admin user |
+
+### Creating a Client via API
+
+```bash
+# Login first (saves session cookie)
+curl -c cookies.txt -X POST http://localhost:8080/admin/login \
+ -H "Content-Type: application/json" \
+ -d '{"username":"admin","password":"admin123"}'
+
+# Create a new client
+curl -b cookies.txt -X POST http://localhost:8080/admin/client/create \
+ -H "Content-Type: application/json" \
+ -d '{
+ "client_id": "myclient",
+ "api_key": "secretkey123",
+ "storage_type": "s3",
+ "dataset": "backup/myclient",
+ "max_size_bytes": 107374182400,
+ "enabled": true,
+ "rotation_policy": {
+ "keep_hourly": 24,
+ "keep_daily": 7,
+ "keep_weekly": 4,
+ "keep_monthly": 12
+ }
+ }'
+```
+
## Architecture
```