From c672fccce3c580a9c4c5f3461978438fd9131588 Mon Sep 17 00:00:00 2001 From: Marek Goc Date: Fri, 13 Feb 2026 19:44:00 +0100 Subject: [PATCH] add admin panel --- .env | 10 +- clients.json | 16 - cmd/zfs-server/main.go | 25 +- go.mod | 8 + go.sum | 47 ++ internal/client/config.go | 4 +- internal/server/admin.go | 1212 +++++++++++++++++++++++++++++++++++ internal/server/config.go | 6 +- internal/server/database.go | 654 +++++++++++++++++++ internal/server/server.go | 304 ++++----- readme.md | 154 ++++- 11 files changed, 2185 insertions(+), 255 deletions(-) delete mode 100644 clients.json create mode 100644 internal/server/admin.go create mode 100644 internal/server/database.go 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 + + + +
+

🔐 Admin Login

+ +
+
+ + +
+
+ + +
+ +
+
+ + + + + + + + + + + +` + +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 ```