add admin panel

This commit is contained in:
2026-02-13 19:44:00 +01:00
parent fb9bb2fc82
commit c672fccce3
11 changed files with 2185 additions and 255 deletions

10
.env
View File

@@ -3,7 +3,7 @@
# =========================================== # ===========================================
# S3 Configuration (Server) # S3 Configuration (Server)
S3_ENABLED=false S3_ENABLED=true
S3_ENDPOINT=localhost:9000 S3_ENDPOINT=localhost:9000
S3_ACCESS_KEY=minioadmin S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin S3_SECRET_KEY=minioadmin
@@ -12,8 +12,9 @@ S3_USE_SSL=false
# Local ZFS Configuration (Server) # Local ZFS Configuration (Server)
ZFS_BASE_DATASET=backup ZFS_BASE_DATASET=backup
CONFIG_FILE=clients.json
METADATA_FILE=metadata.json # Database Configuration (Server)
DATABASE_PATH=zfs-backup.db
# Server Configuration # Server Configuration
PORT=8080 PORT=8080
@@ -23,9 +24,8 @@ PORT=8080
# =========================================== # ===========================================
CLIENT_ID=client1 CLIENT_ID=client1
# NOTE: Use the RAW API key here, not the hashed version! # 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 API_KEY=secret123
SERVER_URL=http://localhost:8080 SERVER_URL=http://localhost:8080
LOCAL_DATASET=volume/test LOCAL_DATASET=volume/test
COMPRESS=true COMPRESS=true
STORAGE_TYPE=local

View File

@@ -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
}
}
]

View File

@@ -5,7 +5,6 @@ package main
import ( import (
"log" "log"
"net/http" "net/http"
"os"
"time" "time"
"git.ma-al.com/goc_marek/zfs/internal/server" "git.ma-al.com/goc_marek/zfs/internal/server"
@@ -31,23 +30,12 @@ func main() {
localBackend := server.NewLocalBackend(cfg.BaseDataset) localBackend := server.NewLocalBackend(cfg.BaseDataset)
// Create metadata directory if needed (only if path contains a directory) // Initialize server with SQLite database
if idx := len(cfg.MetadataFile) - 1; idx > 0 { srv, err := server.New(cfg.DatabasePath, s3Backend, localBackend)
dir := cfg.MetadataFile if err != nil {
foundSlash := false log.Fatalf("Failed to initialize server: %v", err)
for i := len(dir) - 1; i >= 0; i-- {
if dir[i] == '/' {
dir = dir[:i]
foundSlash = true
break
} }
} defer srv.Close()
if foundSlash && dir != "" {
os.MkdirAll(dir, 0755)
}
}
srv := server.New(cfg.ConfigFile, cfg.MetadataFile, s3Backend, localBackend)
// Register HTTP routes // Register HTTP routes
mux := http.NewServeMux() mux := http.NewServeMux()
@@ -63,8 +51,7 @@ func main() {
} }
log.Printf("ZFS Snapshot Server starting on port %s", cfg.Port) log.Printf("ZFS Snapshot Server starting on port %s", cfg.Port)
log.Printf("Config file: %s", cfg.ConfigFile) log.Printf("Database: %s", cfg.DatabasePath)
log.Printf("Metadata file: %s", cfg.MetadataFile)
log.Printf("S3 enabled: %v", cfg.S3Enabled) log.Printf("S3 enabled: %v", cfg.S3Enabled)
if err := httpServer.ListenAndServe(); err != nil { if err := httpServer.ListenAndServe(); err != nil {

8
go.mod
View File

@@ -5,6 +5,7 @@ go 1.25.6
require ( require (
github.com/minio/minio-go/v7 v7.0.98 github.com/minio/minio-go/v7 v7.0.98
github.com/mistifyio/go-zfs v2.1.1+incompatible github.com/mistifyio/go-zfs v2.1.1+incompatible
modernc.org/sqlite v1.45.0
) )
require ( require (
@@ -15,16 +16,23 @@ require (
github.com/klauspost/compress v1.18.2 // indirect github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/cpuid/v2 v2.2.11 // indirect github.com/klauspost/cpuid/v2 v2.2.11 // indirect
github.com/klauspost/crc32 v1.3.0 // 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/crc64nvme v1.1.1 // indirect
github.com/minio/md5-simd v1.1.2 // 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/philhofer/fwd v1.2.0 // indirect
github.com/pmezard/go-difflib v1.0.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/rs/xid v1.6.0 // indirect
github.com/tinylib/msgp v1.6.1 // indirect github.com/tinylib/msgp v1.6.1 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.46.0 // 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/net v0.48.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.32.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // 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
) )

47
go.sum
View File

@@ -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/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 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= 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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= 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= 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/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 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= 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 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= 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/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 h1:gAMO1HM9xBRONLHHYnu5iFsOJUiJdNZo6oqSENd4eW8=
github.com/mistifyio/go-zfs v2.1.1+incompatible/go.mod h1:8AuVvqP/mXw1px98n46wfvcGfQ4ci2FwoAjKYxuo3Z4= 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 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= 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= 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= 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 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= 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 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= 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 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= 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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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=

View File

@@ -9,6 +9,7 @@ import (
) )
// Config holds client-side configuration for connecting to the backup server. // 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 { type Config struct {
// ClientID is the unique identifier for this client // ClientID is the unique identifier for this client
ClientID string `json:"client_id"` ClientID string `json:"client_id"`
@@ -20,8 +21,6 @@ type Config struct {
LocalDataset string `json:"local_dataset"` LocalDataset string `json:"local_dataset"`
// Compress enables gzip compression for transfers // Compress enables gzip compression for transfers
Compress bool `json:"compress"` 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. // 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"), ServerURL: getEnv("SERVER_URL", "http://backup-server:8080"),
LocalDataset: getEnv("LOCAL_DATASET", "tank/data"), LocalDataset: getEnv("LOCAL_DATASET", "tank/data"),
Compress: getEnv("COMPRESS", "true") == "true", Compress: getEnv("COMPRESS", "true") == "true",
StorageType: getEnv("STORAGE_TYPE", "s3"),
} }
} }

1212
internal/server/admin.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -15,8 +15,7 @@ type Config struct {
S3UseSSL bool S3UseSSL bool
S3Enabled bool // Enable/disable S3 backend S3Enabled bool // Enable/disable S3 backend
BaseDataset string BaseDataset string
ConfigFile string DatabasePath string // Path to SQLite database
MetadataFile string
Port string Port string
} }
@@ -42,8 +41,7 @@ func LoadConfig() *Config {
S3UseSSL: getEnv("S3_USE_SSL", "true") != "false", S3UseSSL: getEnv("S3_USE_SSL", "true") != "false",
S3Enabled: s3Enabled, S3Enabled: s3Enabled,
BaseDataset: getEnv("ZFS_BASE_DATASET", "backup"), BaseDataset: getEnv("ZFS_BASE_DATASET", "backup"),
ConfigFile: getEnv("CONFIG_FILE", "clients.json"), DatabasePath: getEnv("DATABASE_PATH", "zfs-backup.db"),
MetadataFile: getEnv("METADATA_FILE", "metadata.json"),
Port: getEnv("PORT", "8080"), Port: getEnv("PORT", "8080"),
} }
} }

654
internal/server/database.go Normal file
View File

@@ -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
}

View File

@@ -9,131 +9,60 @@ import (
"io" "io"
"log" "log"
"net/http" "net/http"
"os"
"sort"
"strings" "strings"
"sync"
"time" "time"
) )
// Server manages snapshots from multiple clients with S3 support // Server manages snapshots from multiple clients with S3 support
type Server struct { type Server struct {
clients map[string]*ClientConfig db *Database
snapshots map[string][]*SnapshotMetadata
mu sync.RWMutex
s3Backend *S3Backend s3Backend *S3Backend
localBackend *LocalBackend localBackend *LocalBackend
metadataFile string
configFile string
} }
// New creates a new snapshot server // New creates a new snapshot server with SQLite database
func New(configFile, metadataFile string, s3Backend *S3Backend, localBackend *LocalBackend) *Server { 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{ s := &Server{
clients: make(map[string]*ClientConfig), db: db,
snapshots: make(map[string][]*SnapshotMetadata),
s3Backend: s3Backend, s3Backend: s3Backend,
localBackend: localBackend, localBackend: localBackend,
metadataFile: metadataFile,
configFile: configFile,
} }
s.loadConfig() return s, nil
s.loadMetadata()
return s
} }
func (s *Server) loadConfig() { // Close closes the database connection
data, err := os.ReadFile(s.configFile) func (s *Server) Close() error {
if err != nil { return s.db.Close()
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)
}
} }
func (s *Server) authenticate(clientID, apiKey string) bool { func (s *Server) authenticate(clientID, apiKey string) bool {
s.mu.RLock() client, err := s.db.GetClient(clientID)
defer s.mu.RUnlock() if err != nil || client == nil {
return false
}
client, exists := s.clients[clientID] if !client.Enabled {
if !exists || !client.Enabled {
return false return false
} }
@@ -141,22 +70,13 @@ func (s *Server) authenticate(clientID, apiKey string) bool {
} }
func (s *Server) getClientUsage(clientID string) int64 { func (s *Server) getClientUsage(clientID string) int64 {
s.mu.RLock() usage, _ := s.db.GetClientUsage(clientID)
defer s.mu.RUnlock() return usage
var total int64
for _, snap := range s.snapshots[clientID] {
total += snap.SizeBytes
}
return total
} }
func (s *Server) canAcceptSnapshot(clientID string, estimatedSize int64) (bool, string) { func (s *Server) canAcceptSnapshot(clientID string, estimatedSize int64) (bool, string) {
s.mu.RLock() client, err := s.db.GetClient(clientID)
defer s.mu.RUnlock() if err != nil || client == nil {
client, exists := s.clients[clientID]
if !exists {
return false, "Client not found" return false, "Client not found"
} }
@@ -171,42 +91,36 @@ func (s *Server) canAcceptSnapshot(clientID string, estimatedSize int64) (bool,
} }
func (s *Server) rotateSnapshots(clientID string) (int, int64) { func (s *Server) rotateSnapshots(clientID string) (int, int64) {
// First pass: collect snapshots to delete while holding lock client, err := s.db.GetClient(clientID)
s.mu.Lock() if err != nil || client == nil {
client, exists := s.clients[clientID]
if !exists {
s.mu.Unlock()
return 0, 0 return 0, 0
} }
snapshots := s.snapshots[clientID] currentUsage := s.getClientUsage(clientID)
if len(snapshots) == 0 { if currentUsage <= client.MaxSizeBytes {
s.mu.Unlock()
return 0, 0 return 0, 0
} }
// Sort by timestamp (oldest first) // Calculate how many bytes we need to free
sort.Slice(snapshots, func(i, j int) bool { bytesToFree := currentUsage - client.MaxSizeBytes
return snapshots[i].Timestamp.Before(snapshots[j].Timestamp) var deletedCount int
}) var reclaimedBytes int64
currentUsage := int64(0) // Get oldest snapshots and delete until we're under quota
for _, snap := range snapshots { snapshots, err := s.db.GetOldestSnapshots(clientID, 100) // Get up to 100 oldest
currentUsage += snap.SizeBytes if err != nil {
log.Printf("Error getting oldest snapshots: %v", err)
return 0, 0
} }
// Collect snapshots to delete
var toDelete []*SnapshotMetadata var toDelete []*SnapshotMetadata
for currentUsage > client.MaxSizeBytes && len(snapshots) > 1 { for _, snap := range snapshots {
oldest := snapshots[0] if reclaimedBytes >= bytesToFree {
toDelete = append(toDelete, oldest) break
currentUsage -= oldest.SizeBytes }
snapshots = snapshots[1:] toDelete = append(toDelete, snap)
reclaimedBytes += snap.SizeBytes
} }
// Update state before I/O
s.snapshots[clientID] = snapshots
s.mu.Unlock()
if len(toDelete) == 0 { if len(toDelete) == 0 {
return 0, 0 return 0, 0
@@ -214,30 +128,31 @@ func (s *Server) rotateSnapshots(clientID string) (int, int64) {
// Select appropriate backend // Select appropriate backend
var backend StorageBackend var backend StorageBackend
if client.StorageType == "s3" { if s.s3Backend != nil {
backend = s.s3Backend backend = s.s3Backend
} else { } else if s.localBackend != nil {
backend = s.localBackend backend = s.localBackend
} else {
log.Printf("No storage backend available for rotation")
return 0, 0
} }
// Second pass: delete without holding lock // Delete snapshots
deletedCount := 0
reclaimedBytes := int64(0)
ctx := context.Background() ctx := context.Background()
for _, snap := range toDelete { for _, snap := range toDelete {
if err := backend.Delete(ctx, snap.StorageKey); err != nil { if err := backend.Delete(ctx, snap.StorageKey); err != nil {
log.Printf("Error deleting snapshot %s: %v", snap.StorageKey, err) log.Printf("Error deleting snapshot %s: %v", snap.StorageKey, err)
continue continue
} }
log.Printf("Rotated out snapshot: %s (freed %d bytes)", snap.StorageKey, snap.SizeBytes) if err := s.db.DeleteSnapshot(clientID, snap.SnapshotID); err != nil {
reclaimedBytes += snap.SizeBytes log.Printf("Error deleting snapshot record %s: %v", snap.SnapshotID, err)
deletedCount++ continue
} }
// Save metadata after deletions log.Printf("Rotated out snapshot: %s (freed %d bytes)", snap.StorageKey, snap.SizeBytes)
s.saveMetadata() deletedCount++
}
return deletedCount, reclaimedBytes return deletedCount, reclaimedBytes
} }
@@ -283,9 +198,14 @@ func (s *Server) HandleUpload(w http.ResponseWriter, r *http.Request) {
return return
} }
s.mu.RLock() client, err := s.db.GetClient(req.ClientID)
client := s.clients[req.ClientID] if err != nil || client == nil {
s.mu.RUnlock() respondJSON(w, http.StatusInternalServerError, UploadResponse{
Success: false,
Message: "Failed to get client configuration",
})
return
}
timestamp := time.Now().Format("2006-01-02_15:04:05") 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 actualSize = size
} }
// Save metadata // Save metadata to database
s.mu.Lock()
metadata := &SnapshotMetadata{ metadata := &SnapshotMetadata{
ClientID: clientID, ClientID: clientID,
SnapshotID: storageKey, SnapshotID: storageKey,
@@ -393,10 +312,10 @@ func (s *Server) HandleUploadStream(w http.ResponseWriter, r *http.Request) {
Incremental: incrementalStr == "true", Incremental: incrementalStr == "true",
BaseSnapshot: baseSnapshot, 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{}{ respondJSON(w, http.StatusOK, map[string]interface{}{
"success": true, "success": true,
@@ -415,10 +334,17 @@ func (s *Server) HandleStatus(w http.ResponseWriter, r *http.Request) {
return return
} }
s.mu.RLock() client, err := s.db.GetClient(clientID)
client := s.clients[clientID] if err != nil || client == nil {
snapshots := s.snapshots[clientID] respondJSON(w, http.StatusInternalServerError, StatusResponse{Success: false})
s.mu.RUnlock() return
}
snapshots, err := s.db.GetSnapshotsByClient(clientID)
if err != nil {
log.Printf("Error getting snapshots: %v", err)
snapshots = []*SnapshotMetadata{}
}
usedBytes := s.getClientUsage(clientID) usedBytes := s.getClientUsage(clientID)
percentUsed := float64(usedBytes) / float64(client.MaxSizeBytes) * 100 percentUsed := float64(usedBytes) / float64(client.MaxSizeBytes) * 100
@@ -477,18 +403,14 @@ func (s *Server) HandleDownload(w http.ResponseWriter, r *http.Request) {
} }
// Find snapshot metadata // Find snapshot metadata
s.mu.RLock() client, err := s.db.GetClient(clientID)
client := s.clients[clientID] if err != nil || client == nil {
var targetSnapshot *SnapshotMetadata http.Error(w, "Client not found", http.StatusNotFound)
for _, snap := range s.snapshots[clientID] { return
if snap.SnapshotID == snapshotID {
targetSnapshot = snap
break
} }
}
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) http.Error(w, "Snapshot not found", http.StatusNotFound)
return return
} }
@@ -496,10 +418,13 @@ func (s *Server) HandleDownload(w http.ResponseWriter, r *http.Request) {
ctx := context.Background() ctx := context.Background()
var backend StorageBackend var backend StorageBackend
if client.StorageType == "s3" { if client.StorageType == "s3" && s.s3Backend != nil {
backend = s.s3Backend backend = s.s3Backend
} else { } else if s.localBackend != nil {
backend = s.localBackend backend = s.localBackend
} else {
http.Error(w, "No storage backend available", http.StatusInternalServerError)
return
} }
// Download from storage // Download from storage
@@ -543,11 +468,8 @@ func (s *Server) HandleRotationPolicy(w http.ResponseWriter, r *http.Request) {
return return
} }
s.mu.RLock() client, err := s.db.GetClient(clientID)
client, exists := s.clients[clientID] if err != nil || client == nil {
s.mu.RUnlock()
if !exists {
respondJSON(w, http.StatusNotFound, RotationPolicyResponse{ respondJSON(w, http.StatusNotFound, RotationPolicyResponse{
Success: false, Success: false,
Message: "Client not found", Message: "Client not found",
@@ -577,6 +499,7 @@ func (s *Server) HandleRotationPolicy(w http.ResponseWriter, r *http.Request) {
// RegisterRoutes registers all HTTP routes // RegisterRoutes registers all HTTP routes
func (s *Server) RegisterRoutes(mux *http.ServeMux) { func (s *Server) RegisterRoutes(mux *http.ServeMux) {
// Client API routes
mux.HandleFunc("/upload", s.HandleUpload) mux.HandleFunc("/upload", s.HandleUpload)
mux.HandleFunc("/upload-stream/", s.HandleUploadStream) mux.HandleFunc("/upload-stream/", s.HandleUploadStream)
mux.HandleFunc("/status", s.HandleStatus) mux.HandleFunc("/status", s.HandleStatus)
@@ -584,6 +507,25 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/download", s.HandleDownload) mux.HandleFunc("/download", s.HandleDownload)
mux.HandleFunc("/health", s.HandleHealth) mux.HandleFunc("/health", s.HandleHealth)
mux.HandleFunc("/rotation-policy", s.HandleRotationPolicy) 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{}) { func respondJSON(w http.ResponseWriter, status int, data interface{}) {

154
readme.md
View File

@@ -76,12 +76,15 @@ S3_USE_SSL=true
# Local ZFS fallback # Local ZFS fallback
ZFS_BASE_DATASET=backup ZFS_BASE_DATASET=backup
# Database Configuration (SQLite)
DATABASE_PATH=zfs-backup.db
# Server settings # Server settings
CONFIG_FILE=clients.json
METADATA_FILE=metadata.json
PORT=8080 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 ### Client Configuration
```env ```env
@@ -90,10 +93,11 @@ API_KEY=secret123
SERVER_URL=http://backup-server:8080 SERVER_URL=http://backup-server:8080
LOCAL_DATASET=tank/data LOCAL_DATASET=tank/data
COMPRESS=true 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 ### Restore Tool Configuration
@@ -344,35 +348,55 @@ S3_BUCKET=zfs-backups
S3_USE_SSL=true 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 - **Admin users**: Authentication credentials for the admin panel
[ - **Client configurations**: Authentication, quotas, storage type, rotation policies
{ - **Snapshot metadata**: Timestamps, sizes, storage keys, incremental relationships
"client_id": "client1",
"api_key": "hashed_key", ### Database Schema
"max_size_bytes": 107374182400,
"dataset": "backup/client1", The database contains four main tables:
"enabled": true,
"storage_type": "s3", **admins table:**
"rotation_policy": { - `id` - Unique identifier
"keep_hourly": 24, - `username` - Admin username (unique)
"keep_daily": 7, - `password_hash` - SHA-256 hashed password
"keep_weekly": 4, - `role` - Admin role (default: "admin")
"keep_monthly": 12 - `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 ### 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 - **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 - **Client-Autonomous**: If no rotation policy is set, the client uses its default policy
The rotation policy fields are: The rotation policy fields are:
- `keep_hourly`: Number of hourly snapshots to keep (default: 24) - `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 ## Architecture
``` ```