Files
backer/internal/backer/newServer.go
eleksir 8beba095d0 Add ability to select compression algo by downloading location with file extension.
- Also update README.md with more useful examples.
2026-04-04 11:49:33 +03:00

237 lines
6.0 KiB
Go

package backer
import (
"context"
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
"path"
"strings"
"time"
"backer/internal/log"
)
// withServerHeader wraps a handler to add Server header to all responses.
func withServerHeader(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Server", "backer")
next.ServeHTTP(w, r)
})
}
// NewServer starts new server instance with given config file.
func NewServer(configPath string) (*http.Server, error) {
_ = log.Init("info", "") // Initialize logger with defaults for early logging.
if err := LoadConfig(configPath); err != nil {
return nil, fmt.Errorf("Failed to load config: %w", err)
}
if err := log.Init(C.LogLevel, C.Log); err != nil {
log.Errorf("Failed to initialize logger: %v", err)
}
mux := http.NewServeMux()
mux.HandleFunc(C.Location, func(w http.ResponseWriter, r *http.Request) {
username, password, ok := r.BasicAuth()
// No auth? Good bye.
if !ok {
w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Both username AND password must match. Explicitly require that the whole condition
// fails when either is incorrect, per the authentication design.
//nolint:staticcheck // QF1001: De Morgan's law is irrelevant here.
if !(username == C.User && password == C.Password) {
w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
clientIP := getClientIP(r)
backupStart := time.Now()
log.Infof("Backup started: client=%s user=%s", clientIP, username)
ctx := r.Context()
files, err := GetFilesFromDirectories(C.Directories)
if err != nil {
log.Errorf("Failed to get files: %v", err)
w.Header().Set("Server", "backer")
http.Error(w, "Failed to get files", http.StatusInternalServerError)
return
}
var (
archive io.ReadCloser
extension string
algorithm string
)
requestedExt := strings.TrimPrefix(path.Ext(r.URL.Path), ".")
algorithm = getCompressionAlgorithm(requestedExt)
switch algorithm {
case "bzip2":
archive = CreateTarBzip2Stream(ctx, files)
extension = "tar.bz2"
case "zstd":
archive = CreateTarZstdStream(ctx, files)
extension = "tar.zst"
case "lz4":
archive = CreateTarLz4Stream(ctx, files)
extension = "tar.lz4"
case "xz":
archive = CreateTarXzStream(ctx, files)
extension = "tar.xz"
case "pgzip":
archive = CreateTarPgzipStream(ctx, files)
extension = "tar.gz"
default:
archive = CreateTarGzStream(ctx, files)
extension = "tar.gz"
}
defer func() {
if err := archive.Close(); err != nil {
log.Errorf("Failed to close archive reader: %v", err)
}
}()
timestamp := time.Now().Format("20060102-150405")
filename := fmt.Sprintf("%s-%s.%s", C.FilenamePrefix, timestamp, extension)
w.Header().Set("Content-Disposition", "attachment; filename="+filename)
w.Header().Set("Content-Type", "application/octet-stream")
bytesWritten, err := io.Copy(w, archive)
duration := time.Since(backupStart)
if err != nil {
log.Errorf("Backup failed: client=%s user=%s duration=%s error=%v", clientIP, username, duration, err)
return
}
log.Infof("Backup completed: client=%s user=%s files=%d bytes=%d duration=%s", clientIP, username, len(files), bytesWritten, duration)
})
// HTTP mode if nohttps is enabled in config.
// HTTPS mode if nohttps is disabled in config.
if C.NoHTTPS {
return &http.Server{
Addr: fmt.Sprintf("%s:%d", C.Address, C.Port),
Handler: withServerHeader(mux),
ReadHeaderTimeout: readHeaderTimeout,
WriteTimeout: time.Duration(C.BackupTimeout) * time.Minute,
}, nil
}
return &http.Server{
Addr: fmt.Sprintf("%s:%d", C.Address, C.Port),
Handler: withServerHeader(mux),
ReadHeaderTimeout: readHeaderTimeout,
WriteTimeout: time.Duration(C.BackupTimeout) * time.Minute,
TLSConfig: &tls.Config{
MinVersion: tls.VersionTLS13,
},
}, nil
}
// writeWithContext checks if context is canceled before executing fn.
// Returns ctx.Err() if context is already canceled, otherwise returns fn().
func writeWithContext(ctx context.Context, fn func() error) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
return fn()
}
// copyWithContext copies data from src to dst using a 32KB buffer, respecting
// context cancellation. If the context is canceled, the copy stops and returns
// ctx.Err().
func copyWithContext(ctx context.Context, dst io.Writer, src io.Reader) error {
buf := make([]byte, copyBufferSize)
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
n, readErr := src.Read(buf)
if n > 0 {
if _, werr := dst.Write(buf[:n]); werr != nil {
return werr
}
}
if readErr != nil {
if readErr == io.EOF {
return nil
}
return readErr
}
}
}
// getClientIP extracts the client IP address from the request.
// It checks X-Forwarded-For header first (for proxied requests), then falls back to RemoteAddr.
func getClientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
return strings.TrimSpace(strings.Split(xff, ",")[0])
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// getCompressionAlgorithm determines which compression algorithm to use based on the requested extension.
// If the extension is recognized, it returns the corresponding algorithm; otherwise, it returns the default.
// For .tar.gz extension, pgzip is always used for parallel compression.
func getCompressionAlgorithm(requestedExt string) string {
switch requestedExt {
case "tar.bz2":
return "bzip2"
case "tar.zst":
return "zstd"
case "tar.lz4":
return "lz4"
case "tar.xz":
return "xz"
case "tar.gz":
return "pgzip"
default:
return C.DefaultCompression
}
}
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */