Initial commit
This commit is contained in:
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
# Binary
|
||||
/backer
|
||||
/backer.exe
|
||||
|
||||
# Local config (may contain secrets)
|
||||
/config.json
|
||||
|
||||
# Test coverage
|
||||
/coverage.out
|
||||
/coverage.html
|
||||
|
||||
# Accidental log files in source
|
||||
/internal/backer/info
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Build artifacts
|
||||
*.exe
|
||||
*.test
|
||||
*.out
|
||||
331
.golangci.yml
Normal file
331
.golangci.yml
Normal file
@@ -0,0 +1,331 @@
|
||||
version: "2"
|
||||
run:
|
||||
go: "1.25"
|
||||
modules-download-mode: readonly
|
||||
tests: false
|
||||
allow-parallel-runners: true
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- asasalint
|
||||
- asciicheck
|
||||
- bodyclose
|
||||
- containedctx
|
||||
- contextcheck
|
||||
- decorder
|
||||
- durationcheck
|
||||
- errcheck
|
||||
- errchkjson
|
||||
- errorlint
|
||||
- exhaustive
|
||||
- exptostd
|
||||
- ginkgolinter
|
||||
- gocheckcompilerdirectives
|
||||
- gocritic
|
||||
- godoclint
|
||||
- godot
|
||||
- godox
|
||||
- goprintffuncname
|
||||
- gosec
|
||||
- gosmopolitan
|
||||
- govet
|
||||
- grouper
|
||||
- importas
|
||||
- ineffassign
|
||||
- intrange
|
||||
- ireturn
|
||||
- makezero
|
||||
- mirror
|
||||
- misspell
|
||||
- modernize
|
||||
- nakedret
|
||||
- nilerr
|
||||
- nilnil
|
||||
- nlreturn
|
||||
- noctx
|
||||
- nolintlint
|
||||
- nonamedreturns
|
||||
- perfsprint
|
||||
- predeclared
|
||||
- promlinter
|
||||
- reassign
|
||||
- revive
|
||||
- rowserrcheck
|
||||
- staticcheck
|
||||
- unconvert
|
||||
- unused
|
||||
- usestdlibvars
|
||||
- wastedassign
|
||||
- wsl_v5
|
||||
settings:
|
||||
decorder:
|
||||
dec-order:
|
||||
- type
|
||||
- const
|
||||
- var
|
||||
- func
|
||||
gocritic:
|
||||
disable-all: false
|
||||
enable-all: true
|
||||
disabled-checks:
|
||||
- singleCaseSwitch
|
||||
- octalLiteral
|
||||
- regexpSimplify
|
||||
- paramTypeCombine
|
||||
- whyNoLint
|
||||
- hugeParam
|
||||
godoclint:
|
||||
default: all
|
||||
ignore-unexported: false
|
||||
ignore-exported: false
|
||||
include-unexported: true
|
||||
disable:
|
||||
- max-len
|
||||
godot:
|
||||
scope: all
|
||||
exclude:
|
||||
# Exclude todo and fixme comments.
|
||||
- "^fixme:"
|
||||
- "^todo:"
|
||||
period: true
|
||||
capital: false
|
||||
godox:
|
||||
keywords:
|
||||
- FIXME
|
||||
gosec: # go-fucking-sic
|
||||
excludes:
|
||||
# Боже, как же вы затрахали своей крипто-ебучкой, наркоманы хреновы!
|
||||
- G405 # Detect the usage of DES or RC4
|
||||
- G406 # Detect the usage of MD4 or RIPEMD160
|
||||
# Оно не используется для повышения безопасности или каких-то таких секъюрных вещей, оно используется для выборки
|
||||
# произвольного элемента из массива, так что ИДИ НАХУЙ с этой проверкой и не еби мозги липовой безопасностью.
|
||||
- G404 # Insecure random number source (rand)
|
||||
- G501 # Import blocklist: crypto/md5
|
||||
- G502 # Import blocklist: crypto/des
|
||||
- G503 # Import blocklist: crypto/rc4
|
||||
- G504 # Import blocklist: net/http/cgi
|
||||
- G505 # Import blocklist: crypto/sha1
|
||||
- G506 # Import blocklist: golang.org/x/crypto/md4
|
||||
- G507 # Import blocklist: golang.org/x/crypto/ripemd160
|
||||
config:
|
||||
global:
|
||||
audit: true
|
||||
# Ваша сраная паранойя при создании файлов и каталогов мешает нормальной работе сервисов. Группы придуманы для
|
||||
# удобства, группе удобно давать право чтения. Поэтому идите нахуй со своими ограничениями на чтение для
|
||||
# группы, "безопасные" клоуны.
|
||||
G301: "0755"
|
||||
G306: "0644"
|
||||
govet:
|
||||
disable-all: false
|
||||
enable-all: true
|
||||
disable:
|
||||
- shadow
|
||||
- fieldalignment
|
||||
grouper:
|
||||
# Require the use of a single global 'const' declaration only.
|
||||
# Default: false
|
||||
const-require-single-const: true
|
||||
# Require the use of grouped global 'const' declarations.
|
||||
# Default: false
|
||||
const-require-grouping: true
|
||||
# Require the use of a single 'import' declaration only.
|
||||
# Default: false
|
||||
import-require-single-import: true
|
||||
# Require the use of grouped 'import' declarations.
|
||||
# Default: false
|
||||
import-require-grouping: true
|
||||
# Require the use of a single global 'type' declaration only.
|
||||
# Default: false
|
||||
type-require-single-type: true
|
||||
# Require the use of grouped global 'type' declarations.
|
||||
# Default: false
|
||||
type-require-grouping: true
|
||||
# Require the use of a single global 'var' declaration only.
|
||||
# Default: false
|
||||
var-require-single-var: true
|
||||
# Require the use of grouped global 'var' declarations.
|
||||
# Default: false
|
||||
var-require-grouping: true
|
||||
misspell:
|
||||
# Correct spellings using locale preferences for US or UK.
|
||||
# Setting locale to US will correct the British spelling of 'colour' to 'color'.
|
||||
# Default is to use a neutral variety of English.
|
||||
locale: US
|
||||
# Typos to ignore.
|
||||
# Should be in lower case.
|
||||
# Default: []
|
||||
ignore-rules:
|
||||
- someword
|
||||
# Extra word corrections.
|
||||
# `typo` and `correction` should only contain letters.
|
||||
# The words are case-insensitive.
|
||||
# Default: []
|
||||
extra-words:
|
||||
- typo: "iff"
|
||||
correction: "if"
|
||||
- typo: "cancelation"
|
||||
correction: "cancellation"
|
||||
# Mode of the analysis:
|
||||
# - default: checks all the file content.
|
||||
# - restricted: checks only comments.
|
||||
# Default: ""
|
||||
mode: restricted
|
||||
nonamedreturns:
|
||||
# Report named error if it is assigned inside defer.
|
||||
# Default: false
|
||||
report-error-in-defer: true
|
||||
perfsprint:
|
||||
integer-format: true
|
||||
int-conversion: true
|
||||
error-format: true
|
||||
err-error: false
|
||||
errorf: true
|
||||
string-format: true
|
||||
sprintf1: true
|
||||
strconcat: true
|
||||
bool-format: true
|
||||
hex-format: true
|
||||
concat-loop: true
|
||||
loop-other-ops: false
|
||||
revive:
|
||||
enable-all-rules: false
|
||||
staticcheck:
|
||||
# https://staticcheck.dev/docs/configuration/options/#dot_import_whitelist
|
||||
# Default: ["github.com/mmcloughlin/avo/build", "github.com/mmcloughlin/avo/operand", "github.com/mmcloughlin/avo/reg"]
|
||||
dot-import-whitelist:
|
||||
- fmt
|
||||
# https://staticcheck.dev/docs/configuration/options/#initialisms
|
||||
# Default: ["ACL", "API", "ASCII", "CPU", "CSS", "DNS", "EOF", "GUID", "HTML", "HTTP", "HTTPS", "ID", "IP", "JSON", "QPS", "RAM", "RPC", "SLA", "SMTP", "SQL", "SSH", "TCP", "TLS", "TTL", "UDP", "UI", "GID", "UID", "UUID", "URI", "URL", "UTF8", "VM", "XML", "XMPP", "XSRF", "XSS", "SIP", "RTP", "AMQP", "DB", "TS"]
|
||||
initialisms:
|
||||
[
|
||||
"ACL",
|
||||
"API",
|
||||
"ASCII",
|
||||
"CPU",
|
||||
"CSS",
|
||||
"DNS",
|
||||
"EOF",
|
||||
"GUID",
|
||||
"HTML",
|
||||
"HTTP",
|
||||
"HTTPS",
|
||||
"ID",
|
||||
"IP",
|
||||
"JSON",
|
||||
"QPS",
|
||||
"RAM",
|
||||
"RPC",
|
||||
"SLA",
|
||||
"SMTP",
|
||||
"SQL",
|
||||
"SSH",
|
||||
"TCP",
|
||||
"TLS",
|
||||
"TTL",
|
||||
"UDP",
|
||||
"UI",
|
||||
"GID",
|
||||
"UID",
|
||||
"UUID",
|
||||
"URI",
|
||||
"URL",
|
||||
"UTF8",
|
||||
"VM",
|
||||
"XML",
|
||||
"XMPP",
|
||||
"XSRF",
|
||||
"XSS",
|
||||
"SIP",
|
||||
"RTP",
|
||||
"AMQP",
|
||||
"DB",
|
||||
"TS",
|
||||
]
|
||||
# https://staticcheck.dev/docs/configuration/options/#http_status_code_whitelist
|
||||
# Default: ["200", "400", "404", "500"]
|
||||
http-status-code-whitelist: ["200", "400", "404", "500"]
|
||||
# SAxxxx checks in https://staticcheck.dev/docs/configuration/options/#checks
|
||||
# Example (to disable some checks): [ "all", "-SA1000", "-SA1001"]
|
||||
# Run `GL_DEBUG=staticcheck golangci-lint run --enable=staticcheck` to see all available checks and enabled by config checks.
|
||||
# Default: ["all", "-ST1000", "-ST1003", "-ST1016", "-ST1020", "-ST1021", "-ST1022"]
|
||||
checks:
|
||||
# Enable all checks, then disable specific ones
|
||||
- all
|
||||
# Disable ST1005 - allows capitalized error messages
|
||||
- -ST1005
|
||||
unconvert:
|
||||
# Remove conversions that force intermediate rounding.
|
||||
# Default: false
|
||||
fast-math: true
|
||||
# Be more conservative (experimental).
|
||||
# Default: false
|
||||
safe: true
|
||||
usestdlibvars:
|
||||
# Suggest the use of http.MethodXX.
|
||||
# Default: true
|
||||
http-method: false
|
||||
# Suggest the use of http.StatusXX.
|
||||
# Default: true
|
||||
http-status-code: false
|
||||
# Suggest the use of time.Month in time.Date.
|
||||
# Default: false
|
||||
time-date-month: true
|
||||
# Suggest the use of time.Weekday.String().
|
||||
# Default: true
|
||||
time-weekday: true
|
||||
# Suggest the use of time.Month.String().
|
||||
# Default: false
|
||||
time-month: true
|
||||
# Suggest the use of time.Layout.
|
||||
# Default: false
|
||||
time-layout: true
|
||||
# Suggest the use of crypto.Hash.String().
|
||||
# Default: false
|
||||
crypto-hash: true
|
||||
# Suggest the use of rpc.DefaultXXPath.
|
||||
# Default: false
|
||||
default-rpc-path: true
|
||||
# Suggest the use of sql.LevelXX.String().
|
||||
# Default: false
|
||||
sql-isolation-level: true
|
||||
# Suggest the use of tls.SignatureScheme.String().
|
||||
# Default: false
|
||||
tls-signature-scheme: true
|
||||
# Suggest the use of constant.Kind.String().
|
||||
# Default: false
|
||||
constant-kind: true
|
||||
wsl_v5:
|
||||
allow-first-in-block: true
|
||||
allow-whole-block: true
|
||||
branch-max-lines: 2
|
||||
case-max-lines: 2
|
||||
default: all
|
||||
disable:
|
||||
- if
|
||||
- err
|
||||
exclusions:
|
||||
generated: lax
|
||||
presets:
|
||||
- comments
|
||||
- common-false-positives
|
||||
- legacy
|
||||
- std-error-handling
|
||||
rules:
|
||||
# Allow capitalized error messages
|
||||
- linters:
|
||||
- staticcheck
|
||||
text: "ST1005"
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
- goimports
|
||||
exclusions:
|
||||
generated: lax
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Backer Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
53
Makefile
Normal file
53
Makefile
Normal file
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env gmake -f
|
||||
|
||||
.PHONY: all build clean test upgrade help
|
||||
|
||||
BINARY=backer
|
||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
LDFLAGS=-ldflags="-s -w -X main.version=$(VERSION)"
|
||||
BUILDOPTS=-a -gcflags=all=-l -trimpath -buildvcs=false
|
||||
|
||||
# Binary name with .exe for Windows
|
||||
ifdef GOOS
|
||||
ifeq ($(GOOS),windows)
|
||||
BINARY=backer.exe
|
||||
endif
|
||||
else ifeq ($(OS),Windows_NT)
|
||||
BINARY=backer.exe
|
||||
endif
|
||||
|
||||
all: clean build
|
||||
|
||||
build:
|
||||
CGO_ENABLED=0 go build $(BUILDOPTS) $(LDFLAGS) -o $(BINARY) ./cmd/backer
|
||||
|
||||
clean:
|
||||
rm -f $(BINARY)
|
||||
|
||||
upgrade:
|
||||
go get -u ./...
|
||||
go mod tidy
|
||||
go mod vendor
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
help:
|
||||
@echo "Backer build system"
|
||||
@echo ""
|
||||
@echo "Usage: make [target]"
|
||||
@echo ""
|
||||
@echo "Targets:"
|
||||
@echo " build Build the binary"
|
||||
@echo " clean Remove built binary"
|
||||
@echo " test Run all tests"
|
||||
@echo " upgrade Update dependencies"
|
||||
@echo " help Show this help message"
|
||||
@echo ""
|
||||
@echo "Variables:"
|
||||
@echo " VERSION Set version (default: git describe)"
|
||||
@echo ""
|
||||
@echo "Example:"
|
||||
@echo " make build"
|
||||
|
||||
# vim: set ft=make noet ai ts=4 sw=4 sts=4:
|
||||
79
README.md
Normal file
79
README.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Backer
|
||||
|
||||
Kind of backup utility. It makes tar.gz on-the-fly out of given in config.json files.
|
||||
|
||||
## How to build it
|
||||
|
||||
```bash
|
||||
make # Build binary
|
||||
make build VERSION=0.1.0 # Build with version
|
||||
make test # Run tests
|
||||
backer # Run
|
||||
backer --version # Show version
|
||||
backer -c /path/to/config.json # Custom config
|
||||
```
|
||||
|
||||
That will produce binary named `backer`. Copy it somewhere in `/usr/local/sbin/backer`, for example. In `data`
|
||||
directory resides `config_example.json`. Using this file make working config, put it into `/etc/backer.json`.
|
||||
|
||||
Service ready to run.
|
||||
|
||||
You can copy systemd unit from contrib directory to /etc/systemd/system and make usual rituals required for systemd.
|
||||
|
||||
If you running alpine linux or gentoo, you can use openrc init script from contrib dir.
|
||||
|
||||
If you running FreeBSD, use the rc.d script `contrib/backer.freebsd` and place it in `/usr/local/etc/rc.d/`.
|
||||
|
||||
## How to use it
|
||||
|
||||
Build, install and configure backer. On your backup storage server you can do
|
||||
|
||||
```bash
|
||||
curl -u username:password https://your_server:8086/archive -o "backup-$(date +%Y%m%d-%H%M%S).tar.gz"
|
||||
```
|
||||
|
||||
and you'll get your backup with timestamp in filename, like `backup-20260325-105100.tar.gz`
|
||||
|
||||
Of course, something can go wrong, so you must check http status code after downloading backup.
|
||||
|
||||
## About config options
|
||||
|
||||
AI suggests to fill this section with useful information, despite that all options described in example config.
|
||||
|
||||
| Option | Default value | Wtf | Notes |
|
||||
| :--- | :---- | :--- | :--- |
|
||||
| address | "0.0.0.0" | Address at which server listens. | In many cases 0.0.0.0 is okay. |
|
||||
| port | 8086 | Port at which server binds | Number picked after intel first comercially successful cpu. |
|
||||
| cert | "/path/to/ssl.crt" | Path to ssl certificate file | It is not enforced in obvious form, but you should limit access to certificate at least with file permissions. |
|
||||
| key | "/path/to/ssl.key" | Path to ssl certificates key file | It is not enforced in obvious form, but you should limit access to certificates key at least with file permissions. |
|
||||
| nohttps | false | ability to disable https and use plain http. | This option is for lab experiments or for development needs. In case of this utility you should consider using long enough certificate with well ciphers. |
|
||||
| location | "/archive" | This is static location where curl (or more sophisticated client) should aim to download backup | /archive is okay, but you free to choose something more wild like "/backup", just for giggles. |
|
||||
| user | | You should pick up some flashy username for basic auth to prevent unauthorized access to your backup. | Enough said. |
|
||||
| password | | You should pick up something super-duper secretous to prevent unauthorized access to your backup. | Enough said. |
|
||||
| log | stderr | Where log messages are directed. If no value set, logs drops to stderr | |
|
||||
| loglevel | | Verbosity of logs. | Can be error, warn, info, debug. Pick whatever you like. |
|
||||
| directories | | An array with directories. Backer can work only with directories, not single files. | Put here all the folders you'd like to backup! |
|
||||
| backup_timeout | 60 | Timeout in minutes for backup streaming operations. | If a backup takes longer than this, the connection will be terminated. Useful for large backups. Range: 1-1440 minutes (1 day max). |
|
||||
| compression_level | 9 | Gzip compression level for tar.gz archives. | 1 (fastest) to 9 (best compression). Higher values produce smaller files but take longer to compress. |
|
||||
| exclude_patterns | [] | Regex patterns to exclude from backup. | E.g., `[".*\\.tmp$", "/node_modules/"]`. Files matching any pattern are skipped. |
|
||||
|
||||
## Best practices
|
||||
|
||||
Traditional yada-yada-yada about internet threats.
|
||||
|
||||
It is entirely possible to use this utility without https/certificate. But you should not do this, really. Validity
|
||||
of certificate is up to you, it can be even expired, but it should be well secured with reasonable key length and
|
||||
good enough cypher.
|
||||
|
||||
And one more thing user and password are mandatory, we should have at least some minimal barriers, right?
|
||||
|
||||
## Special Thanks
|
||||
|
||||
This project was developed with the assistance of **opencode** (AI co-programmer powered by MIMO). The AI helped with:
|
||||
- Code implementation and refactoring
|
||||
- Test coverage
|
||||
- Documentation
|
||||
- Bug fixes
|
||||
- Linting and code style consistency
|
||||
|
||||
Big thanks to the AI for doing most of the boring stuff while I was drinking coffee.
|
||||
14
TODO.org
Normal file
14
TODO.org
Normal file
@@ -0,0 +1,14 @@
|
||||
#+TITLE: Backer TODO List
|
||||
#+AUTHOR: Backer Project
|
||||
#+DATE: <2026-03-25 Wed>
|
||||
|
||||
* TODO Backer Tasks
|
||||
|
||||
** TODO Consider different compression algorithms
|
||||
- [ ] Evaluate alternatives to gzip: bz2, xz, lzo, lz4
|
||||
- [ ] lz4 for speed, xz for better compression
|
||||
- [ ] Add compression_algorithm config option
|
||||
|
||||
** TODO Configurable filename prefix
|
||||
- [ ] Move "backup" prefix to config (currently hardcoded in newServer.go)
|
||||
- [ ] Default: "backup", e.g., "backup-20260325-092341.tar.gz"
|
||||
76
cmd/backer/main.go
Normal file
76
cmd/backer/main.go
Normal file
@@ -0,0 +1,76 @@
|
||||
// Package main implements minimal wrapper to run backer as go application.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"backer/internal/backer"
|
||||
"backer/internal/log"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
var (
|
||||
// Version is set at build time via ldflags.
|
||||
version = "dev"
|
||||
)
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("c", "", "path to config file (default: OS-specific location)")
|
||||
showVersion := flag.Bool("version", false, "show version and exit")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
if *showVersion {
|
||||
fmt.Printf("backer %s\n", version)
|
||||
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
cfgPath := *configPath
|
||||
if cfgPath == "" {
|
||||
cfgPath = defaultConfigPath()
|
||||
}
|
||||
|
||||
server, err := backer.NewServer(cfgPath)
|
||||
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create server: %v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
log.Infof("Starting server at %s", server.Addr)
|
||||
|
||||
if backer.C.NoHTTPS {
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("Failed to start server: %v", errors.WithStack(err))
|
||||
}
|
||||
} else {
|
||||
if _, err := os.Stat(backer.C.Cert); err != nil {
|
||||
log.Fatalf("Certificate file error: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(backer.C.Key); err != nil {
|
||||
log.Fatalf("Certificate key file error: %v", err)
|
||||
}
|
||||
|
||||
if err := server.ListenAndServeTLS(backer.C.Cert, backer.C.Key); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("Failed to start server: %v", errors.WithStack(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// defaultConfigPath returns the default config file path based on the OS.
|
||||
func defaultConfigPath() string {
|
||||
switch runtime.GOOS {
|
||||
case "freebsd", "netbsd", "openbsd", "dragonfly":
|
||||
return "/usr/local/etc/backer.json"
|
||||
default: // Linux, macOS, and others.
|
||||
return "/etc/backer.json"
|
||||
}
|
||||
}
|
||||
|
||||
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
18
contrib/backer
Normal file
18
contrib/backer
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/sbin/openrc-run
|
||||
|
||||
name="$SVCNAME"
|
||||
command="/var/lib/$SVCNAME/$SVCNAME"
|
||||
supervisor="supervise-daemon"
|
||||
pidfile="/run/$SVCNAME/$SVCNAME.pid"
|
||||
|
||||
depend() {
|
||||
need net
|
||||
use dns
|
||||
}
|
||||
|
||||
start_pre() {
|
||||
mkdir -p /var/log/$SVCNAME
|
||||
mkdir -p /run/$SVCNAME
|
||||
}
|
||||
|
||||
# vim: set ft=sh noet ai ts=4 sw=4 sts=4:
|
||||
30
contrib/backer.freebsd
Normal file
30
contrib/backer.freebsd
Normal file
@@ -0,0 +1,30 @@
|
||||
#!/bin/sh
|
||||
|
||||
# PROVIDE: backer
|
||||
# REQUIRE: NETWORKING
|
||||
# BEFORE: DAEMON
|
||||
|
||||
. /etc/rc.subr
|
||||
|
||||
name="backer"
|
||||
rcvar="backer_enable"
|
||||
command="/usr/local/sbin/backer"
|
||||
command_args=""
|
||||
pidfile="/var/run/${name}.pid"
|
||||
|
||||
load_rc_config $name
|
||||
|
||||
: ${backer_enable:="no"}
|
||||
: ${backer_user:="root"}
|
||||
: ${backer_group:="wheel"}
|
||||
: ${backer_logfile:="/var/log/backer.log"}
|
||||
|
||||
start_cmd="${name}_start"
|
||||
|
||||
backer_start()
|
||||
{
|
||||
echo "Starting ${name}."
|
||||
/usr/sbin/daemon -p ${pidfile} -f ${command} ${command_args}
|
||||
}
|
||||
|
||||
run_rc_command "$1"
|
||||
14
contrib/backer.service
Normal file
14
contrib/backer.service
Normal file
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=Backer Backup Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/sbin/backer
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
37
data/config_example.json
Normal file
37
data/config_example.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
/* Listen on other addresses is pointless. */
|
||||
"address": "0.0.0.0",
|
||||
|
||||
/* By default listen on port 8086, like first intel commercially successful cpu model. */
|
||||
"port": 8086,
|
||||
|
||||
/* Path location of certificate file, never use example from test_data! */
|
||||
"cert": "path/to/certificate.crt",
|
||||
|
||||
/* Path location of certificate key, never use example from test_data! */
|
||||
"key": "/path/to/certificate.key",
|
||||
|
||||
/* We really want to use https for backup archive tranferring. */
|
||||
"nohttps": false,
|
||||
|
||||
/* Which location we should query by our client curl, for example. */
|
||||
"location": "/archive",
|
||||
|
||||
/* User for basic auth. */
|
||||
"user": "secret",
|
||||
|
||||
/* Password for basic auth. */
|
||||
"password": "Hush-hush secret password",
|
||||
|
||||
/* Where to store logs. */
|
||||
"log": "/var/log/backer.log",
|
||||
|
||||
/* Verbosity of logs */
|
||||
"loglevel": "info",
|
||||
|
||||
/* Diretories to backup */
|
||||
"directories": [
|
||||
"/path/to/directory1",
|
||||
"/path/to/directory2"
|
||||
]
|
||||
}
|
||||
8
go.mod
Normal file
8
go.mod
Normal file
@@ -0,0 +1,8 @@
|
||||
module backer
|
||||
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/hjson/hjson-go v3.3.0+incompatible
|
||||
github.com/pkg/errors v0.9.1
|
||||
)
|
||||
4
go.sum
Normal file
4
go.sum
Normal file
@@ -0,0 +1,4 @@
|
||||
github.com/hjson/hjson-go v3.3.0+incompatible h1:Rqr+Ya+0aCJMjaE4s8E9YKvuJLuLVpEvz4ONum52vnI=
|
||||
github.com/hjson/hjson-go v3.3.0+incompatible/go.mod h1:qsetwF8NlsTsOTwZTApNlTCerV+b2GjYRRcIk4JMFio=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
53
internal/backer/getFilesFromDirectories.go
Normal file
53
internal/backer/getFilesFromDirectories.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// Package backer implements simple backup server.
|
||||
package backer
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
|
||||
"backer/internal/log"
|
||||
)
|
||||
|
||||
// isExcluded checks if path matches any of the configured exclude patterns.
|
||||
func isExcluded(path string) bool {
|
||||
for _, re := range excludePatterns {
|
||||
if re.MatchString(path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// GetFilesFromDirectories makes a file list of given directories.
|
||||
func GetFilesFromDirectories(directories []string) ([]string, error) {
|
||||
var files []string
|
||||
|
||||
for _, dir := range directories {
|
||||
err := filepath.WalkDir(dir, func(path string, de fs.DirEntry, err error) error { //nolint: revive
|
||||
if err != nil {
|
||||
log.Warnf("Skipping %s: %v", path, err)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if isExcluded(path) {
|
||||
log.Debugf("Excluding: %s", path)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
files = append(files, path)
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Warnf("Failed to walk directory %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
return files, nil
|
||||
}
|
||||
|
||||
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
269
internal/backer/getFilesFromDirectories_test.go
Normal file
269
internal/backer/getFilesFromDirectories_test.go
Normal file
@@ -0,0 +1,269 @@
|
||||
package backer
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// normalizePath converts backslashes to forward slashes for cross-platform consistency.
|
||||
func normalizePath(p string) string {
|
||||
return strings.ReplaceAll(p, "\\", "/")
|
||||
}
|
||||
|
||||
// TestGetFilesFromDirectories checks if GetFilesFromDirectories returns the proper amount of strings, correct strings and no error.
|
||||
func TestGetFilesFromDirectories(t *testing.T) {
|
||||
var (
|
||||
input = Config{
|
||||
Directories: []string{"../../test_data/test1/foo", "../../test_data/test1/bar"},
|
||||
}
|
||||
expectedData = []string{
|
||||
"../../test_data/test1/foo",
|
||||
"../../test_data/test1/foo/empty_dir",
|
||||
"../../test_data/test1/foo/hello_breakfast.txt",
|
||||
"../../test_data/test1/foo/mydir",
|
||||
"../../test_data/test1/foo/mydir/myfile.txt",
|
||||
"../../test_data/test1/foo/some_text.txt",
|
||||
"../../test_data/test1/bar",
|
||||
"../../test_data/test1/bar/goodbye.txt",
|
||||
"../../test_data/test1/bar/goodbye.txt/text.txt",
|
||||
"../../test_data/test1/bar/hello.txt",
|
||||
}
|
||||
)
|
||||
|
||||
output, err := GetFilesFromDirectories(input.Directories)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
outputLen := len(output)
|
||||
expectedDataLen := len(expectedData)
|
||||
|
||||
if outputLen != expectedDataLen {
|
||||
t.Errorf("length of output of GetFilesFromDirectories() does not match length of expected data: %d vs %d", outputLen, expectedDataLen)
|
||||
}
|
||||
|
||||
for _, filepath := range output {
|
||||
normalized := normalizePath(filepath)
|
||||
if !slices.Contains(expectedData, normalized) {
|
||||
t.Errorf("expectedData data does not contain string from output: %s", filepath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetFilesFromDirectoriesSingleDir tests with a single directory.
|
||||
func TestGetFilesFromDirectoriesSingleDir(t *testing.T) {
|
||||
output, err := GetFilesFromDirectories([]string{"../../test_data/test1/foo"})
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(output) == 0 {
|
||||
t.Error("Expected non-empty output")
|
||||
}
|
||||
|
||||
// Should contain the directory itself
|
||||
if !slices.Contains(output, "../../test_data/test1/foo") {
|
||||
t.Error("Output should contain the root directory")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetFilesFromDirectoriesEmptyDir tests with an empty directory.
|
||||
func TestGetFilesFromDirectoriesEmptyDir(t *testing.T) {
|
||||
// Create temp empty dir
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
output, err := GetFilesFromDirectories([]string{tmpDir})
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
// Should contain at least the directory itself
|
||||
if len(output) != 1 {
|
||||
t.Errorf("Expected 1 entry (the directory itself), got %d", len(output))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetFilesFromDirectoriesNonExistent tests with non-existent directory.
|
||||
func TestGetFilesFromDirectoriesNonExistent(t *testing.T) {
|
||||
output, err := GetFilesFromDirectories([]string{"/nonexistent/path/that/does/not/exist"})
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error (logs warning instead), got %v", err)
|
||||
}
|
||||
|
||||
if len(output) != 0 {
|
||||
t.Errorf("Expected empty output for non-existent directory, got %d files", len(output))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetFilesFromDirectoriesMultipleDirs tests multiple directories.
|
||||
func TestGetFilesFromDirectoriesMultipleDirs(t *testing.T) {
|
||||
output, err := GetFilesFromDirectories([]string{
|
||||
"../../test_data/test1/foo",
|
||||
"../../test_data/test1/bar",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
// Should contain entries from both directories
|
||||
hasFoo := slices.ContainsFunc(output, func(s string) bool {
|
||||
return s == "../../test_data/test1/foo" || s == "../../test_data/test1/foo/hello_breakfast.txt"
|
||||
})
|
||||
hasBar := slices.ContainsFunc(output, func(s string) bool {
|
||||
return s == "../../test_data/test1/bar" || s == "../../test_data/test1/bar/hello.txt"
|
||||
})
|
||||
|
||||
if !hasFoo {
|
||||
t.Error("Output should contain entries from foo directory")
|
||||
}
|
||||
if !hasBar {
|
||||
t.Error("Output should contain entries from bar directory")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetFilesFromDirectoriesSymlink tests symlink handling.
|
||||
func TestGetFilesFromDirectoriesSymlink(t *testing.T) {
|
||||
// Create temp dir with symlink
|
||||
tmpDir := t.TempDir()
|
||||
targetFile := tmpDir + "/target.txt"
|
||||
symlinkFile := tmpDir + "/link.txt"
|
||||
|
||||
// Create target file
|
||||
if err := os.WriteFile(targetFile, []byte("test"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create symlink
|
||||
if err := os.Symlink(targetFile, symlinkFile); err != nil {
|
||||
t.Skip("Symlinks not supported, skipping test")
|
||||
}
|
||||
|
||||
output, err := GetFilesFromDirectories([]string{tmpDir})
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
// Should contain the symlink
|
||||
if !slices.ContainsFunc(output, func(s string) bool {
|
||||
return s == symlinkFile
|
||||
}) {
|
||||
t.Error("Output should contain the symlink")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsExcluded(t *testing.T) {
|
||||
// Save original excludePatterns and restore after test
|
||||
originalPatterns := excludePatterns
|
||||
defer func() { excludePatterns = originalPatterns }()
|
||||
|
||||
// Test empty patterns
|
||||
excludePatterns = nil
|
||||
if isExcluded("/some/path") {
|
||||
t.Error("Expected no exclusion with empty patterns")
|
||||
}
|
||||
|
||||
// Test matching pattern
|
||||
excludePatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`\.tmp$`),
|
||||
regexp.MustCompile(`/node_modules/`),
|
||||
}
|
||||
if !isExcluded("/tmp/foo.tmp") {
|
||||
t.Error("Expected exclusion for .tmp file")
|
||||
}
|
||||
if !isExcluded("/project/node_modules/package") {
|
||||
t.Error("Expected exclusion for node_modules path")
|
||||
}
|
||||
if isExcluded("/tmp/foo.txt") {
|
||||
t.Error("Expected no exclusion for .txt file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFilesFromDirectoriesWithExcludes(t *testing.T) {
|
||||
// Save original excludePatterns and restore after test
|
||||
originalPatterns := excludePatterns
|
||||
defer func() { excludePatterns = originalPatterns }()
|
||||
|
||||
// Create a temporary directory with some files
|
||||
tmpDir := t.TempDir()
|
||||
// Create files that should be included
|
||||
err := os.WriteFile(tmpDir+"/keep.txt", []byte("keep"), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Create files that should be excluded
|
||||
err = os.WriteFile(tmpDir+"/skip.tmp", []byte("skip"), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Create a subdirectory with file
|
||||
subDir := tmpDir + "/sub"
|
||||
err = os.Mkdir(subDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = os.WriteFile(subDir+"/file.txt", []byte("file"), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Create a .tmp file in subdirectory
|
||||
err = os.WriteFile(subDir+"/skip.tmp", []byte("skip"), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Set exclude pattern for .tmp files
|
||||
excludePatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`\.tmp$`),
|
||||
}
|
||||
|
||||
output, err := GetFilesFromDirectories([]string{tmpDir})
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Expect entries: tmpDir, keep.txt, subDir, sub/file.txt
|
||||
// Should NOT contain any .tmp files
|
||||
expected := []string{
|
||||
tmpDir,
|
||||
tmpDir + "/keep.txt",
|
||||
subDir,
|
||||
subDir + "/file.txt",
|
||||
}
|
||||
|
||||
// Normalize paths for cross-platform comparison
|
||||
normalizedOutput := make([]string, len(output))
|
||||
for i, p := range output {
|
||||
normalizedOutput[i] = normalizePath(p)
|
||||
}
|
||||
normalizedExpected := make([]string, len(expected))
|
||||
for i, p := range expected {
|
||||
normalizedExpected[i] = normalizePath(p)
|
||||
}
|
||||
|
||||
if len(normalizedOutput) != len(normalizedExpected) {
|
||||
t.Errorf("Expected %d entries, got %d: %v", len(normalizedExpected), len(normalizedOutput), normalizedOutput)
|
||||
}
|
||||
for _, exp := range normalizedExpected {
|
||||
found := slices.Contains(normalizedOutput, exp)
|
||||
if !found {
|
||||
t.Errorf("Expected entry %q not found", exp)
|
||||
}
|
||||
}
|
||||
// Ensure no .tmp files appear
|
||||
for _, path := range normalizedOutput {
|
||||
if strings.HasSuffix(path, ".tmp") {
|
||||
t.Errorf("Excluded file %q appeared in output", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
14
internal/backer/globals.go
Normal file
14
internal/backer/globals.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package backer
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
)
|
||||
|
||||
var (
|
||||
// C contains the global configuration loaded from config file.
|
||||
C Config
|
||||
// excludePatterns contains compiled regex patterns from C.ExcludePatterns.
|
||||
excludePatterns []*regexp.Regexp
|
||||
)
|
||||
|
||||
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
148
internal/backer/loadConfig.go
Normal file
148
internal/backer/loadConfig.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package backer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
|
||||
"backer/internal/log"
|
||||
|
||||
hjson "github.com/hjson/hjson-go"
|
||||
)
|
||||
|
||||
// LoadConfig loads config file to global variable C.
|
||||
func LoadConfig(path string) error {
|
||||
var tmp map[string]any
|
||||
|
||||
buf, err := os.ReadFile(path)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to read config file: %w", err)
|
||||
}
|
||||
|
||||
if err = hjson.Unmarshal(buf, &tmp); err != nil {
|
||||
return fmt.Errorf("Unable to parse config: %w", err)
|
||||
}
|
||||
|
||||
buf, err = json.Marshal(tmp)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("Unable to convert config: %w", err)
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(buf, &C); err != nil {
|
||||
return fmt.Errorf("Failed to decode config file: %w", err)
|
||||
}
|
||||
|
||||
// Address: default and no range validation needed.
|
||||
if C.Address == "" {
|
||||
C.Address = "0.0.0.0"
|
||||
|
||||
log.Warnf("Config option address is not set, fallback to %s", C.Address)
|
||||
}
|
||||
|
||||
// Port: set default, then validate range.
|
||||
if C.Port == 0 {
|
||||
C.Port = 8086
|
||||
|
||||
log.Warnf("Config option port is not set, fallback to %d", C.Port)
|
||||
}
|
||||
|
||||
if C.Port < 1 || C.Port > 65535 {
|
||||
return fmt.Errorf("Port must be between 1 and 65535, got %d", C.Port)
|
||||
}
|
||||
|
||||
// Location: default and no range validation needed.
|
||||
if C.Location == "" {
|
||||
C.Location = "/archive"
|
||||
|
||||
log.Warnf("Config option location is not set, fallback to %s", C.Location)
|
||||
}
|
||||
|
||||
// Log: default only.
|
||||
if C.Log == "" {
|
||||
log.Warn("Config option log is not set, fallback to stderr")
|
||||
}
|
||||
|
||||
// LogLevel: default and no range validation needed.
|
||||
if C.LogLevel == "" {
|
||||
C.LogLevel = "info"
|
||||
|
||||
log.Warnf("Config option loglevel is not set, fallback to %s", C.LogLevel)
|
||||
}
|
||||
|
||||
// Directories: required, then validate existence.
|
||||
if len(C.Directories) == 0 {
|
||||
return errors.New("You should configure at least one directory to backup!") //nolint: revive
|
||||
}
|
||||
|
||||
for _, dir := range C.Directories {
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
return fmt.Errorf("Backup directory does not exist: %s", dir)
|
||||
}
|
||||
}
|
||||
|
||||
// BackupTimeout: set default, then validate range.
|
||||
if C.BackupTimeout == 0 {
|
||||
C.BackupTimeout = 60
|
||||
|
||||
log.Warnf("Config option backup_timeout is not set, fallback to %d minutes", C.BackupTimeout)
|
||||
}
|
||||
|
||||
if C.BackupTimeout < 1 || C.BackupTimeout > 1440 {
|
||||
return fmt.Errorf("Backup_timeout must be between 1 and 1440 minutes, got %d", C.BackupTimeout)
|
||||
}
|
||||
|
||||
// CompressionLevel: set default, then validate range.
|
||||
if C.CompressionLevel == 0 {
|
||||
C.CompressionLevel = 9
|
||||
|
||||
log.Warnf("Config option compression_level is not set, fallback to %d", C.CompressionLevel)
|
||||
}
|
||||
|
||||
if C.CompressionLevel < 1 || C.CompressionLevel > 9 {
|
||||
return fmt.Errorf("Compression_level must be between 1 and 9, got %d", C.CompressionLevel)
|
||||
}
|
||||
|
||||
// HTTPS: validate cert/key when HTTPS enabled.
|
||||
if !C.NoHTTPS {
|
||||
switch {
|
||||
case C.Cert == "" && C.Key == "":
|
||||
return errors.New("Config option nohttps set to false, but neither cert nor key options set")
|
||||
case C.Cert == "":
|
||||
return errors.New("Config option nohttps set to false, but no cert option set")
|
||||
case C.Key == "":
|
||||
return errors.New("Config option nohttps set to false, but no key option set")
|
||||
}
|
||||
}
|
||||
|
||||
// User: required.
|
||||
if C.User == "" {
|
||||
return errors.New("Option user is not set")
|
||||
}
|
||||
|
||||
// Password: required.
|
||||
if C.Password == "" {
|
||||
return errors.New("Option password is not set")
|
||||
}
|
||||
|
||||
// Compile exclude patterns.
|
||||
excludePatterns = make([]*regexp.Regexp, 0, len(C.ExcludePatterns))
|
||||
|
||||
for _, pattern := range C.ExcludePatterns {
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
log.Warnf("Invalid exclude pattern %q: %v", pattern, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
excludePatterns = append(excludePatterns, re)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
223
internal/backer/loadConfig_test.go
Normal file
223
internal/backer/loadConfig_test.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package backer
|
||||
|
||||
import (
|
||||
"os"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadConfig(t *testing.T) {
|
||||
// Reset global config
|
||||
C = Config{}
|
||||
|
||||
var (
|
||||
testConfigPath = "./../../test_data/test_config.json"
|
||||
expectedData = Config{
|
||||
Address: "127.0.0.1",
|
||||
Port: 8086,
|
||||
Directories: []string{
|
||||
"../../test_data/test1/foo",
|
||||
"../../test_data/test1/bar",
|
||||
},
|
||||
Location: "/archive",
|
||||
}
|
||||
)
|
||||
|
||||
err := LoadConfig(testConfigPath)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if C.Address != expectedData.Address {
|
||||
t.Errorf("C.Address should contain %s but contains %s", expectedData.Address, C.Address)
|
||||
}
|
||||
|
||||
if C.Port != expectedData.Port {
|
||||
t.Errorf("C.Port should contain %d but containc %d", expectedData.Port, C.Port)
|
||||
}
|
||||
|
||||
if C.Location != expectedData.Location {
|
||||
t.Errorf("C.StreamLocation should contain %s but contains %s", expectedData.Location, C.Location)
|
||||
}
|
||||
|
||||
outputDirectoriesLen := len(C.Directories)
|
||||
expectedDataDirectoriesLen := len(expectedData.Directories)
|
||||
|
||||
if outputDirectoriesLen != expectedDataDirectoriesLen {
|
||||
t.Errorf("length of C.Directories must be %d but is %d", expectedDataDirectoriesLen, outputDirectoriesLen)
|
||||
}
|
||||
|
||||
for _, str := range expectedData.Directories {
|
||||
if !slices.Contains(C.Directories, str) {
|
||||
t.Errorf("C.Directories data does not contain expected string: %s", str)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfigMissingUser tests that config fails when user is missing.
|
||||
func TestLoadConfigMissingUser(t *testing.T) {
|
||||
config := `{
|
||||
"port": 8086,
|
||||
"password": "test",
|
||||
"directories": ["../../test_data/test1/foo"]
|
||||
}`
|
||||
tmpFile := writeTempConfig(t, config)
|
||||
|
||||
err := LoadConfig(tmpFile)
|
||||
if err == nil {
|
||||
t.Error("Expected error for missing user, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfigMissingPassword tests that config fails when password is missing.
|
||||
func TestLoadConfigMissingPassword(t *testing.T) {
|
||||
config := `{
|
||||
"port": 8086,
|
||||
"user": "test",
|
||||
"directories": ["../../test_data/test1/foo"]
|
||||
}`
|
||||
tmpFile := writeTempConfig(t, config)
|
||||
|
||||
err := LoadConfig(tmpFile)
|
||||
if err == nil {
|
||||
t.Error("Expected error for missing password, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfigMissingDirectories tests that config fails when directories is missing.
|
||||
func TestLoadConfigMissingDirectories(t *testing.T) {
|
||||
config := `{
|
||||
"port": 8086,
|
||||
"user": "test",
|
||||
"password": "test"
|
||||
}`
|
||||
tmpFile := writeTempConfig(t, config)
|
||||
|
||||
err := LoadConfig(tmpFile)
|
||||
if err == nil {
|
||||
t.Error("Expected error for missing directories, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfigInvalidPort tests that config fails with invalid port.
|
||||
func TestLoadConfigInvalidPort(t *testing.T) {
|
||||
config := `{
|
||||
"port": 99999,
|
||||
"user": "test",
|
||||
"password": "test",
|
||||
"directories": ["../../test_data/test1/foo"]
|
||||
}`
|
||||
tmpFile := writeTempConfig(t, config)
|
||||
|
||||
err := LoadConfig(tmpFile)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid port, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfigNonExistentDirectory tests that config fails with non-existent directory.
|
||||
func TestLoadConfigNonExistentDirectory(t *testing.T) {
|
||||
config := `{
|
||||
"port": 8086,
|
||||
"user": "test",
|
||||
"password": "test",
|
||||
"directories": ["/nonexistent/path"]
|
||||
}`
|
||||
tmpFile := writeTempConfig(t, config)
|
||||
|
||||
err := LoadConfig(tmpFile)
|
||||
if err == nil {
|
||||
t.Error("Expected error for non-existent directory, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfigHTTPSWithoutCert tests that config fails when HTTPS enabled but no cert.
|
||||
func TestLoadConfigHTTPSWithoutCert(t *testing.T) {
|
||||
config := `{
|
||||
"port": 8086,
|
||||
"user": "test",
|
||||
"password": "test",
|
||||
"directories": ["../../test_data/test1/foo"],
|
||||
"nohttps": false
|
||||
}`
|
||||
tmpFile := writeTempConfig(t, config)
|
||||
|
||||
err := LoadConfig(tmpFile)
|
||||
if err == nil {
|
||||
t.Error("Expected error for HTTPS without cert, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfigNoHTTPS tests that config succeeds with nohttps=true and no cert.
|
||||
func TestLoadConfigNoHTTPS(t *testing.T) {
|
||||
config := `{
|
||||
"port": 8086,
|
||||
"user": "test",
|
||||
"password": "test",
|
||||
"directories": ["../../test_data/test1/foo"],
|
||||
"nohttps": true
|
||||
}`
|
||||
tmpFile := writeTempConfig(t, config)
|
||||
|
||||
err := LoadConfig(tmpFile)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error with nohttps=true, got %v", err)
|
||||
}
|
||||
|
||||
if !C.NoHTTPS {
|
||||
t.Error("C.NoHTTPS should be true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfigDefaultValues tests default values are set correctly.
|
||||
func TestLoadConfigDefaultValues(t *testing.T) {
|
||||
config := `{
|
||||
"user": "test",
|
||||
"password": "test",
|
||||
"directories": ["../../test_data/test1/foo"],
|
||||
"cert": "../../test_data/example.crt",
|
||||
"key": "../../test_data/example.key"
|
||||
}`
|
||||
tmpFile := writeTempConfig(t, config)
|
||||
|
||||
err := LoadConfig(tmpFile)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if C.Address != "0.0.0.0" {
|
||||
t.Errorf("C.Address should default to 0.0.0.0, got %s", C.Address)
|
||||
}
|
||||
|
||||
if C.Port != 8086 {
|
||||
t.Errorf("C.Port should default to 8086, got %d", C.Port)
|
||||
}
|
||||
|
||||
if C.Location != "/archive" {
|
||||
t.Errorf("C.Location should default to /archive, got %s", C.Location)
|
||||
}
|
||||
|
||||
if C.LogLevel != "info" {
|
||||
t.Errorf("C.LogLevel should default to info, got %s", C.LogLevel)
|
||||
}
|
||||
}
|
||||
|
||||
// writeTempConfig is a helper to write a temp config file for testing.
|
||||
func writeTempConfig(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
tmpFile, err := os.CreateTemp(t.TempDir(), "test_config_*.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tmpFile.WriteString(content); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tmpFile.Close()
|
||||
// Reset global config before loading
|
||||
C = Config{}
|
||||
excludePatterns = nil
|
||||
return tmpFile.Name()
|
||||
}
|
||||
|
||||
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
192
internal/backer/newServer.go
Normal file
192
internal/backer/newServer.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package backer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"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) {
|
||||
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. Fuck De Morgan and his bullshit laws - we explicitly require
|
||||
// that the whole condition fails. That *is* the point.
|
||||
//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
|
||||
}
|
||||
|
||||
tarGz := CreateTarGzStream(ctx, files)
|
||||
|
||||
defer func() {
|
||||
if err := tarGz.Close(); err != nil {
|
||||
log.Errorf("Failed to close tar.gz reader: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
timestamp := time.Now().Format("20060102-150405")
|
||||
filename := fmt.Sprintf("backup-%s.tar.gz", timestamp)
|
||||
|
||||
w.Header().Set("Content-Disposition", "attachment; filename="+filename)
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
|
||||
bytesWritten, err := io.Copy(w, tarGz)
|
||||
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)
|
||||
})
|
||||
|
||||
return &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", C.Address, C.Port),
|
||||
Handler: withServerHeader(mux),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
WriteTimeout: time.Duration(C.BackupTimeout) * time.Minute,
|
||||
TLSConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS13,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// writeWithContext executes fn in a goroutine and returns its result.
|
||||
// If the context is canceled before fn completes, ctx.Err() is returned.
|
||||
// fn should perform I/O that will unblock when the underlying pipe is closed
|
||||
// on context cancellation (e.g., writes to a pipe writer). Panics in fn are
|
||||
// recovered and returned as errors.
|
||||
func writeWithContext(ctx context.Context, fn func() error) error {
|
||||
done := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
done <- fmt.Errorf("panic: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
done <- ctx.Err()
|
||||
default:
|
||||
done <- fn()
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case err := <-done:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 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(). Panics are recovered and returned as errors.
|
||||
func copyWithContext(ctx context.Context, dst io.Writer, src io.Reader) (err error) { //nolint:nonamedreturns
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("panic: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
buf := make([]byte, 32*1024)
|
||||
|
||||
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.Split(xff, ",")[0]
|
||||
}
|
||||
|
||||
ip := r.RemoteAddr
|
||||
if colon := strings.LastIndex(ip, ":"); colon != -1 {
|
||||
return ip[:colon]
|
||||
}
|
||||
|
||||
return ip
|
||||
}
|
||||
|
||||
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
374
internal/backer/newServer_test.go
Normal file
374
internal/backer/newServer_test.go
Normal file
@@ -0,0 +1,374 @@
|
||||
package backer
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestNewServerSuccess tests successful server creation.
|
||||
func TestNewServerSuccess(t *testing.T) {
|
||||
C = Config{} // Reset global config.
|
||||
|
||||
server, err := NewServer("./../../test_data/test_config.json")
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if server == nil {
|
||||
t.Fatal("Expected non-nil server")
|
||||
}
|
||||
|
||||
if server.Addr != "127.0.0.1:8086" {
|
||||
t.Errorf("Expected address '127.0.0.1:8086', got '%s'", server.Addr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewServerInvalidConfig tests server creation with invalid config.
|
||||
func TestNewServerInvalidConfig(t *testing.T) {
|
||||
C = Config{} // Reset global config.
|
||||
|
||||
_, err := NewServer("/nonexistent/config.json")
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid config path, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandlerNoAuth tests that requests without auth are rejected.
|
||||
func TestHandlerNoAuth(t *testing.T) {
|
||||
C = Config{
|
||||
Address: "0.0.0.0",
|
||||
Port: 8086,
|
||||
Location: "/archive",
|
||||
User: "testuser",
|
||||
Password: "testpass",
|
||||
Directories: []string{"../../test_data/test1/foo"},
|
||||
}
|
||||
excludePatterns = nil
|
||||
|
||||
req := httptest.NewRequest("GET", "/archive", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Create a handler that mimics the server's handler
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
username, password, ok := r.BasicAuth()
|
||||
|
||||
if !ok {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("Expected status 401, got %d", w.Code)
|
||||
}
|
||||
|
||||
authHeader := w.Header().Get("WWW-Authenticate")
|
||||
if !strings.Contains(authHeader, "Basic") {
|
||||
t.Errorf("Expected WWW-Authenticate header with Basic, got '%s'", authHeader)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandlerWrongCredentials tests that requests with wrong credentials are rejected.
|
||||
func TestHandlerWrongCredentials(t *testing.T) {
|
||||
C = Config{
|
||||
Address: "0.0.0.0",
|
||||
Port: 8086,
|
||||
Location: "/archive",
|
||||
User: "testuser",
|
||||
Password: "testpass",
|
||||
Directories: []string{"../../test_data/test1/foo"},
|
||||
}
|
||||
excludePatterns = nil
|
||||
|
||||
req := httptest.NewRequest("GET", "/archive", nil)
|
||||
req.SetBasicAuth("wronguser", "wrongpass")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
username, password, ok := r.BasicAuth()
|
||||
|
||||
if !ok {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("Expected status 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandlerCorrectCredentials tests that requests with correct credentials succeed.
|
||||
func TestHandlerCorrectCredentials(t *testing.T) {
|
||||
C = Config{
|
||||
Address: "0.0.0.0",
|
||||
Port: 8086,
|
||||
Location: "/archive",
|
||||
User: "testuser",
|
||||
Password: "testpass",
|
||||
Directories: []string{"../../test_data/test1/foo"},
|
||||
}
|
||||
excludePatterns = nil
|
||||
|
||||
req := httptest.NewRequest("GET", "/archive", nil)
|
||||
req.SetBasicAuth("testuser", "testpass")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
username, password, ok := r.BasicAuth()
|
||||
|
||||
if !ok {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if !(username == C.User && password == C.Password) {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandlerArchiveStream tests that the handler streams a valid tar.gz archive.
|
||||
func TestHandlerArchiveStream(t *testing.T) {
|
||||
C = Config{
|
||||
Address: "0.0.0.0",
|
||||
Port: 8086,
|
||||
Location: "/archive",
|
||||
User: "testuser",
|
||||
Password: "testpass",
|
||||
Directories: []string{"../../test_data/test1/foo"},
|
||||
}
|
||||
excludePatterns = nil
|
||||
|
||||
// Create a test server with the actual handler logic
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
username, password, ok := r.BasicAuth()
|
||||
if !ok || !(username == C.User && password == C.Password) {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
files, err := GetFilesFromDirectories(C.Directories)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to get files", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tarGz := CreateTarGzStream(ctx, files)
|
||||
defer tarGz.Close()
|
||||
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=archive.tar")
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
|
||||
io.Copy(w, tarGz)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Make authenticated request
|
||||
req, _ := http.NewRequest("GET", server.URL, nil)
|
||||
req.SetBasicAuth("testuser", "testpass")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Verify it's a valid tar.gz
|
||||
gzReader, err := gzip.NewReader(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create gzip reader: %v", err)
|
||||
}
|
||||
defer gzReader.Close()
|
||||
|
||||
tarReader := tar.NewReader(gzReader)
|
||||
|
||||
foundFile := false
|
||||
for {
|
||||
header, err := tarReader.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read tar entry: %v", err)
|
||||
}
|
||||
|
||||
if strings.Contains(header.Name, "hello_breakfast.txt") {
|
||||
foundFile = true
|
||||
}
|
||||
}
|
||||
|
||||
if !foundFile {
|
||||
t.Error("Expected to find hello_breakfast.txt in archive")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteWithContextSuccess tests successful write with context.
|
||||
func TestWriteWithContextSuccess(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
called := false
|
||||
|
||||
err := writeWithContext(ctx, func() error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Error("Expected function to be called")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteWithContextError tests write with context returning error.
|
||||
func TestWriteWithContextError(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
testErr := io.ErrUnexpectedEOF
|
||||
|
||||
err := writeWithContext(ctx, func() error {
|
||||
return testErr
|
||||
})
|
||||
|
||||
if err != testErr {
|
||||
t.Errorf("Expected %v, got %v", testErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteWithContextCancellation tests write with cancelled context.
|
||||
func TestWriteWithContextCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // Cancel immediately
|
||||
|
||||
err := writeWithContext(ctx, func() error {
|
||||
t.Error("Function should not be called with cancelled context")
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != context.Canceled {
|
||||
t.Errorf("Expected context.Canceled, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCopyWithContextSuccess tests successful copy with context.
|
||||
func TestCopyWithContextSuccess(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
src := strings.NewReader("hello world")
|
||||
dst := &strings.Builder{}
|
||||
|
||||
err := copyWithContext(ctx, dst, src)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if dst.String() != "hello world" {
|
||||
t.Errorf("Expected 'hello world', got '%s'", dst.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCopyWithContextCancellation tests copy with cancelled context.
|
||||
func TestCopyWithContextCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// Create a reader with a lot of data that will take time to copy
|
||||
data := make([]byte, 1024*1024) // 1MB
|
||||
for i := range data {
|
||||
data[i] = byte(i % 256)
|
||||
}
|
||||
src := strings.NewReader(string(data))
|
||||
dst := &strings.Builder{}
|
||||
|
||||
// Start copy in goroutine
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- copyWithContext(ctx, dst, src)
|
||||
}()
|
||||
|
||||
// Cancel context immediately
|
||||
cancel()
|
||||
|
||||
err := <-errCh
|
||||
if err != context.Canceled {
|
||||
t.Errorf("Expected context.Canceled, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteWithContextPanic(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
err := writeWithContext(ctx, func() error {
|
||||
panic("test panic")
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("Expected error from panic, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "panic") {
|
||||
t.Errorf("Expected panic error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyWithContextPanic(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
// Use a reader that panics on Read
|
||||
panicReader := &panicReader{}
|
||||
dst := &strings.Builder{}
|
||||
err := copyWithContext(ctx, dst, panicReader)
|
||||
if err == nil {
|
||||
t.Error("Expected error from panic, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "panic") {
|
||||
t.Errorf("Expected panic error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type panicReader struct{}
|
||||
|
||||
func (pr *panicReader) Read(p []byte) (n int, err error) {
|
||||
panic("read panic")
|
||||
}
|
||||
|
||||
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
23
internal/backer/types.go
Normal file
23
internal/backer/types.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package backer
|
||||
|
||||
type (
|
||||
// Config main config struct.
|
||||
Config struct {
|
||||
Address string `json:"address"`
|
||||
Port int `json:"port"`
|
||||
Cert string `json:"cert"`
|
||||
Key string `json:"key"`
|
||||
NoHTTPS bool `json:"nohttps"`
|
||||
Location string `json:"location"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
Log string `json:"log"`
|
||||
LogLevel string `json:"loglevel"`
|
||||
Directories []string `json:"directories"`
|
||||
BackupTimeout int `json:"backup_timeout"` // Timeout in minutes for backup streaming.
|
||||
CompressionLevel int `json:"compression_level"` // Gzip compression level (1-9, default 9).
|
||||
ExcludePatterns []string `json:"exclude_patterns"` // Regex patterns to exclude from backup.
|
||||
}
|
||||
)
|
||||
|
||||
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
177
internal/backer/utils.go
Normal file
177
internal/backer/utils.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package backer
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"backer/internal/log"
|
||||
)
|
||||
|
||||
// CreateTarGzStream takes a list of files and returns a reader that reads from a tar archive containing these files.
|
||||
func CreateTarGzStream(ctx context.Context, filepaths []string) io.ReadCloser {
|
||||
pr, pw := io.Pipe()
|
||||
|
||||
go func() {
|
||||
var pipeErr error // Track if we close pipe with error.
|
||||
|
||||
// Helper to close pipe with error, only once.
|
||||
closePipeWithError := func(err error) {
|
||||
if pipeErr != nil {
|
||||
return // Already closed with error.
|
||||
}
|
||||
|
||||
pipeErr = err
|
||||
pw.CloseWithError(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
// Only do normal close if we haven't closed with error.
|
||||
if pipeErr == nil {
|
||||
pw.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
gw, err := gzip.NewWriterLevel(pw, C.CompressionLevel)
|
||||
if err != nil {
|
||||
closePipeWithError(fmt.Errorf("gzip writer error: %w", err))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
defer func() {
|
||||
// Only close gzip writer if pipe is still open.
|
||||
if pipeErr == nil {
|
||||
if err := gw.Close(); err != nil {
|
||||
// Ignore pipe closed errors - reader may have closed early.
|
||||
if !isPipeClosedError(err) {
|
||||
log.Errorf("Failed to close gzip writer: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
tw := tar.NewWriter(gw)
|
||||
|
||||
defer func() {
|
||||
// Only close tar writer if pipe is still open.
|
||||
if pipeErr == nil {
|
||||
if err := tw.Close(); err != nil {
|
||||
// Ignore pipe closed errors - reader may have closed early.
|
||||
if !isPipeClosedError(err) {
|
||||
log.Errorf("Failed to close tar writer: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for _, fpath := range filepaths {
|
||||
var (
|
||||
err error
|
||||
linkTarget string
|
||||
)
|
||||
|
||||
// Check for cancellation early.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
closePipeWithError(ctx.Err())
|
||||
|
||||
return
|
||||
|
||||
default:
|
||||
}
|
||||
|
||||
log.Debugf("Adding file: %s", fpath)
|
||||
|
||||
st, err := os.Lstat(fpath)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf("Skipping %s: %v", fpath, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if st.Mode()&os.ModeSymlink != 0 {
|
||||
linkTarget, err = os.Readlink(fpath)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf("Skipping %s: readlink failed: %s", fpath, err)
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Skip sockets explicitly (they cannot be backed up).
|
||||
if st.Mode()&os.ModeSocket != 0 {
|
||||
log.Debugf("Skipping socket: %s", fpath)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
header, err := tar.FileInfoHeader(st, linkTarget)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf("Skipping %s: tar header failed: %v", fpath, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
header.Format = tar.FormatGNU
|
||||
header.Name = strings.TrimPrefix(filepath.Clean(fpath), string(filepath.Separator))
|
||||
|
||||
mode := st.Mode()
|
||||
|
||||
// Write header with cancellation awareness.
|
||||
if err := writeWithContext(ctx, func() error {
|
||||
return tw.WriteHeader(header)
|
||||
}); err != nil {
|
||||
closePipeWithError(err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Only stream file contents for regular files.
|
||||
if mode.IsRegular() {
|
||||
f, err := os.Open(fpath)
|
||||
if err != nil {
|
||||
log.Warnf("Skipping %s: open failed: %v", fpath, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
err = copyWithContext(ctx, tw, f)
|
||||
|
||||
if e := f.Close(); e != nil {
|
||||
log.Warnf("Failed to close file %s: %v", fpath, e)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Warnf("Skipping %s: copy failed: %v", fpath, err)
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}()
|
||||
|
||||
return pr
|
||||
}
|
||||
|
||||
// isPipeClosedError checks if the error is due to a closed pipe.
|
||||
func isPipeClosedError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for the specific pipe closed error message.
|
||||
return err.Error() == "io: read/write on closed pipe" ||
|
||||
err.Error() == "io: read on closed pipe"
|
||||
}
|
||||
|
||||
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
359
internal/backer/utils_test.go
Normal file
359
internal/backer/utils_test.go
Normal file
@@ -0,0 +1,359 @@
|
||||
package backer
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCreateTarGzStreamBasic tests basic tar.gz creation with a single file.
|
||||
func TestCreateTarGzStreamBasic(t *testing.T) {
|
||||
// Create temp file
|
||||
tmpDir := t.TempDir()
|
||||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
content := []byte("hello world")
|
||||
if err := os.WriteFile(testFile, content, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
reader := CreateTarGzStream(ctx, []string{testFile})
|
||||
defer reader.Close()
|
||||
|
||||
// Decompress and read
|
||||
gzReader, err := gzip.NewReader(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create gzip reader: %v", err)
|
||||
}
|
||||
defer gzReader.Close()
|
||||
|
||||
tarReader := tar.NewReader(gzReader)
|
||||
|
||||
// Read the file entry
|
||||
header, err := tarReader.Next()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read tar header: %v", err)
|
||||
}
|
||||
|
||||
if header.Name != testFile && filepath.Base(header.Name) != "test.txt" {
|
||||
t.Errorf("Expected file name 'test.txt', got '%s'", header.Name)
|
||||
}
|
||||
|
||||
if header.Typeflag != tar.TypeReg {
|
||||
t.Errorf("Expected regular file type, got %d", header.Typeflag)
|
||||
}
|
||||
|
||||
// Read content
|
||||
data, err := io.ReadAll(tarReader)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read file content: %v", err)
|
||||
}
|
||||
|
||||
if string(data) != string(content) {
|
||||
t.Errorf("Expected content '%s', got '%s'", content, data)
|
||||
}
|
||||
|
||||
// Should be no more entries
|
||||
if _, err := tarReader.Next(); err != io.EOF {
|
||||
t.Error("Expected EOF after single file")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTarGzStreamMultipleFiles tests with multiple files.
|
||||
func TestCreateTarGzStreamMultipleFiles(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create multiple files
|
||||
files := []string{
|
||||
filepath.Join(tmpDir, "file1.txt"),
|
||||
filepath.Join(tmpDir, "file2.txt"),
|
||||
filepath.Join(tmpDir, "file3.txt"),
|
||||
}
|
||||
|
||||
for i, f := range files {
|
||||
if err := os.WriteFile(f, []byte{byte('0' + i)}, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
reader := CreateTarGzStream(ctx, files)
|
||||
defer reader.Close()
|
||||
|
||||
gzReader, err := gzip.NewReader(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create gzip reader: %v", err)
|
||||
}
|
||||
defer gzReader.Close()
|
||||
|
||||
tarReader := tar.NewReader(gzReader)
|
||||
|
||||
count := 0
|
||||
for {
|
||||
_, err := tarReader.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read tar entry: %v", err)
|
||||
}
|
||||
count++
|
||||
}
|
||||
|
||||
if count != 3 {
|
||||
t.Errorf("Expected 3 entries, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTarGzStreamDirectory tests directory handling.
|
||||
func TestCreateTarGzStreamDirectory(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
subDir := filepath.Join(tmpDir, "subdir")
|
||||
if err := os.Mkdir(subDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
reader := CreateTarGzStream(ctx, []string{subDir})
|
||||
defer reader.Close()
|
||||
|
||||
gzReader, err := gzip.NewReader(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create gzip reader: %v", err)
|
||||
}
|
||||
defer gzReader.Close()
|
||||
|
||||
tarReader := tar.NewReader(gzReader)
|
||||
|
||||
header, err := tarReader.Next()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read tar header: %v", err)
|
||||
}
|
||||
|
||||
if header.Typeflag != tar.TypeDir {
|
||||
t.Errorf("Expected directory type, got %d", header.Typeflag)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTarGzStreamSymlink tests symlink handling.
|
||||
func TestCreateTarGzStreamSymlink(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
targetFile := filepath.Join(tmpDir, "target.txt")
|
||||
symlinkFile := filepath.Join(tmpDir, "link.txt")
|
||||
|
||||
if err := os.WriteFile(targetFile, []byte("target content"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := os.Symlink(targetFile, symlinkFile); err != nil {
|
||||
t.Skip("Symlinks not supported, skipping test")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
reader := CreateTarGzStream(ctx, []string{symlinkFile})
|
||||
defer reader.Close()
|
||||
|
||||
gzReader, err := gzip.NewReader(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create gzip reader: %v", err)
|
||||
}
|
||||
defer gzReader.Close()
|
||||
|
||||
tarReader := tar.NewReader(gzReader)
|
||||
|
||||
header, err := tarReader.Next()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read tar header: %v", err)
|
||||
}
|
||||
|
||||
if header.Typeflag != tar.TypeSymlink {
|
||||
t.Errorf("Expected symlink type, got %c", header.Typeflag)
|
||||
}
|
||||
|
||||
if header.Linkname != targetFile {
|
||||
t.Errorf("Expected linkname '%s', got '%s'", targetFile, header.Linkname)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTarGzStreamCancellation tests context cancellation.
|
||||
func TestCreateTarGzStreamCancellation(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create a file
|
||||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
if err := os.WriteFile(testFile, []byte("test content"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// Cancel before reading
|
||||
cancel()
|
||||
|
||||
reader := CreateTarGzStream(ctx, []string{testFile})
|
||||
defer reader.Close()
|
||||
|
||||
// Read all - should complete without panic
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
// Error is expected due to cancellation
|
||||
t.Logf("Got expected error: %v", err)
|
||||
}
|
||||
|
||||
// We should get some data or an error, but not panic
|
||||
_ = data
|
||||
}
|
||||
|
||||
// TestCreateTarGzStreamNonExistentFile tests handling of non-existent files.
|
||||
func TestCreateTarGzStreamNonExistentFile(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
reader := CreateTarGzStream(ctx, []string{"/nonexistent/path/file.txt"})
|
||||
defer reader.Close()
|
||||
|
||||
gzReader, err := gzip.NewReader(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create gzip reader: %v", err)
|
||||
}
|
||||
defer gzReader.Close()
|
||||
|
||||
tarReader := tar.NewReader(gzReader)
|
||||
|
||||
// Should get EOF immediately since the file doesn't exist and is skipped
|
||||
_, err = tarReader.Next()
|
||||
if err != io.EOF {
|
||||
t.Errorf("Expected EOF for non-existent file, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTarGzStreamEmptyList tests with empty file list.
|
||||
func TestCreateTarGzStreamEmptyList(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
reader := CreateTarGzStream(ctx, []string{})
|
||||
defer reader.Close()
|
||||
|
||||
gzReader, err := gzip.NewReader(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create gzip reader: %v", err)
|
||||
}
|
||||
defer gzReader.Close()
|
||||
|
||||
tarReader := tar.NewReader(gzReader)
|
||||
|
||||
// Should get EOF immediately for empty list
|
||||
_, err = tarReader.Next()
|
||||
if err != io.EOF {
|
||||
t.Errorf("Expected EOF for empty list, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTarGzStreamNestedFiles tests with nested directory structure.
|
||||
func TestCreateTarGzStreamNestedFiles(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create nested structure
|
||||
nestedDir := filepath.Join(tmpDir, "a", "b", "c")
|
||||
if err := os.MkdirAll(nestedDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
nestedFile := filepath.Join(nestedDir, "deep.txt")
|
||||
if err := os.WriteFile(nestedFile, []byte("deep content"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
// Pass the nested file directly, not the root
|
||||
reader := CreateTarGzStream(ctx, []string{nestedFile})
|
||||
defer reader.Close()
|
||||
|
||||
gzReader, err := gzip.NewReader(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create gzip reader: %v", err)
|
||||
}
|
||||
defer gzReader.Close()
|
||||
|
||||
tarReader := tar.NewReader(gzReader)
|
||||
|
||||
foundDeepFile := false
|
||||
for {
|
||||
header, err := tarReader.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read tar entry: %v", err)
|
||||
}
|
||||
|
||||
if filepath.Base(header.Name) == "deep.txt" {
|
||||
foundDeepFile = true
|
||||
|
||||
// Verify content
|
||||
data, err := io.ReadAll(tarReader)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read content: %v", err)
|
||||
}
|
||||
if string(data) != "deep content" {
|
||||
t.Errorf("Expected 'deep content', got '%s'", data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !foundDeepFile {
|
||||
t.Error("Expected to find deep.txt in archive")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTarGzStreamDeviceFile tests that device files are included in the archive.
|
||||
func TestCreateTarGzStreamDeviceFile(t *testing.T) {
|
||||
// Use /dev/null as a test device file (character device).
|
||||
devNull := "/dev/null"
|
||||
st, err := os.Lstat(devNull)
|
||||
if err != nil {
|
||||
t.Skipf("Skipping device test: %v", err)
|
||||
}
|
||||
if st.Mode()&os.ModeCharDevice == 0 {
|
||||
t.Skipf("Skipping: %s is not a character device", devNull)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
reader := CreateTarGzStream(ctx, []string{devNull})
|
||||
defer reader.Close()
|
||||
|
||||
gzReader, err := gzip.NewReader(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create gzip reader: %v", err)
|
||||
}
|
||||
defer gzReader.Close()
|
||||
|
||||
tarReader := tar.NewReader(gzReader)
|
||||
|
||||
// Read the device entry
|
||||
header, err := tarReader.Next()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read tar header: %v", err)
|
||||
}
|
||||
|
||||
if header.Typeflag != tar.TypeChar {
|
||||
t.Errorf("Expected character device type, got %c", header.Typeflag)
|
||||
}
|
||||
|
||||
// Check that the name matches the file path (without leading slash)
|
||||
expectedName := devNull
|
||||
if expectedName[0] == '/' {
|
||||
expectedName = expectedName[1:]
|
||||
}
|
||||
if header.Name != expectedName {
|
||||
t.Errorf("Expected header name %q, got %q", expectedName, header.Name)
|
||||
}
|
||||
|
||||
// Ensure there are no more entries
|
||||
if _, err := tarReader.Next(); err != io.EOF {
|
||||
t.Error("Expected EOF after device entry")
|
||||
}
|
||||
}
|
||||
|
||||
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
148
internal/log/main.go
Normal file
148
internal/log/main.go
Normal file
@@ -0,0 +1,148 @@
|
||||
// Package log implements logging facility as golang slog module wrapper.
|
||||
package log
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
// Log contains *[os.File] pointing to log file descriptor.
|
||||
Log *os.File
|
||||
)
|
||||
|
||||
// Init sets up logger stuff.
|
||||
// level can be error, warn, info, debug; if something other is supplied, info level is selected.
|
||||
func Init(level, filename string) error {
|
||||
var (
|
||||
err error
|
||||
loglevel slog.Level
|
||||
)
|
||||
|
||||
if filename != "" {
|
||||
Log, err = os.OpenFile(filename, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("Unable to open log file: %w", err)
|
||||
}
|
||||
} else {
|
||||
Log = os.Stderr
|
||||
}
|
||||
|
||||
// no panic, no trace.
|
||||
switch level {
|
||||
case "error":
|
||||
loglevel = slog.LevelError
|
||||
|
||||
case "warn":
|
||||
loglevel = slog.LevelWarn
|
||||
|
||||
case "info":
|
||||
loglevel = slog.LevelInfo
|
||||
|
||||
case "debug":
|
||||
loglevel = slog.LevelDebug
|
||||
|
||||
default:
|
||||
loglevel = slog.LevelInfo
|
||||
}
|
||||
|
||||
opts := &slog.HandlerOptions{
|
||||
// Use the ReplaceAttr function on the handler options
|
||||
// to be able to replace any single attribute in the log output.
|
||||
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { //nolint: revive
|
||||
// Check that we are handling the time key.
|
||||
if a.Key != slog.TimeKey {
|
||||
return a
|
||||
}
|
||||
|
||||
t := a.Value.Time()
|
||||
|
||||
// Change the value from a time.Time to a String
|
||||
// where the string has the correct time format.
|
||||
a.Value = slog.StringValue(t.Format(time.DateTime))
|
||||
|
||||
return a
|
||||
},
|
||||
|
||||
Level: loglevel,
|
||||
}
|
||||
|
||||
slog.SetDefault(
|
||||
slog.New(
|
||||
slog.NewTextHandler(
|
||||
Log,
|
||||
opts,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Error passes message directly to [slog.Error()].
|
||||
func Error(message string) {
|
||||
slog.Error(message)
|
||||
}
|
||||
|
||||
// Errorf logs message on error log level and allows to supply arguments in printf() style.
|
||||
func Errorf(format string, a ...any) {
|
||||
slog.Error(fmt.Sprintf(format, a...))
|
||||
}
|
||||
|
||||
// Warn passes message directly to [slog.Warn()].
|
||||
func Warn(message string) {
|
||||
slog.Warn(message)
|
||||
}
|
||||
|
||||
// Warnf logs message on warn log level and allows to supply arguments in printf() style.
|
||||
func Warnf(format string, a ...any) {
|
||||
slog.Warn(fmt.Sprintf(format, a...))
|
||||
}
|
||||
|
||||
// Info passes message directly to [slog.Info()].
|
||||
func Info(message string) {
|
||||
slog.Info(message)
|
||||
}
|
||||
|
||||
// Infof logs message on info log level and allows to supply arguments in printf() style.
|
||||
func Infof(format string, a ...any) {
|
||||
slog.Info(fmt.Sprintf(format, a...))
|
||||
}
|
||||
|
||||
// Debug passes message directly to [slog.Debug()].
|
||||
func Debug(message string) {
|
||||
slog.Debug(message)
|
||||
}
|
||||
|
||||
// Debugf logs message on debug log level and allows to supply arguments in printf() style.
|
||||
func Debugf(format string, a ...any) {
|
||||
slog.Debug(fmt.Sprintf(format, a...))
|
||||
}
|
||||
|
||||
// Fatal passes message directly to [slog.Error()] and quits with status code 1.
|
||||
func Fatal(message string) {
|
||||
slog.Error(message)
|
||||
Close()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Fatalf logs message on error log level and allows to supply arguments in printf() style. After that it quits with
|
||||
// status code 1.
|
||||
func Fatalf(format string, a ...any) {
|
||||
slog.Error(fmt.Sprintf(format, a...))
|
||||
Close()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Close closes log file descriptor.
|
||||
func Close() {
|
||||
if err := Log.Close(); err != nil {
|
||||
msg := fmt.Sprintln("Failed to close log file:", err)
|
||||
slog.Error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
207
llms.txt
Normal file
207
llms.txt
Normal file
@@ -0,0 +1,207 @@
|
||||
# llms.txt for Backer
|
||||
|
||||
## Overview
|
||||
Backer is a Go-based backup utility that creates tar.gz archives on-the-fly and serves them via HTTP/HTTPS with basic authentication.
|
||||
|
||||
## Quick Start
|
||||
```bash
|
||||
make # Build binary
|
||||
make build VERSION=0.1.0 # Build with version
|
||||
backer # Run with default config (/etc/backer.json)
|
||||
backer -c /path/to/config.json # Run with custom config
|
||||
backer --version # Show version
|
||||
backer -h # Show help
|
||||
curl -u user:pass https://server:8086/archive -o backup.tar.gz
|
||||
```
|
||||
|
||||
## Config File Locations
|
||||
- Linux: `/etc/backer.json`
|
||||
- FreeBSD/NetBSD/OpenBSD/DragonFly: `/usr/local/etc/backer.json`
|
||||
- Override with `-c` flag
|
||||
|
||||
## Directory Structure
|
||||
```
|
||||
/
|
||||
├── cmd/backer/main.go # Entry point, CLI flags, server startup
|
||||
├── internal/
|
||||
│ ├── backer/ # Core logic
|
||||
│ │ ├── types.go # Config struct definition
|
||||
│ │ ├── globals.go # Global C Config variable
|
||||
│ │ ├── loadConfig.go # HJSON/JSON config parsing with validation
|
||||
│ │ ├── newServer.go # HTTP server, auth handler, context helpers
|
||||
│ │ ├── utils.go # CreateTarGzStream, isPipeClosedError
|
||||
│ │ ├── getFilesFromDirectories.go # Directory walking, exclusion filtering
|
||||
│ │ └── *_test.go # Test files
|
||||
│ └── log/main.go # Logging wrapper around stdlib slog
|
||||
├── contrib/ # Init scripts
|
||||
│ ├── backer # OpenRC (Alpine/Gentoo)
|
||||
│ ├── backer.service # systemd
|
||||
│ └── backer.freebsd # FreeBSD rc.d
|
||||
├── test_data/ # Test fixtures
|
||||
├── data/config_example.json # Example config
|
||||
├── Makefile # Build targets
|
||||
├── TODO.org # Project backlog
|
||||
├── README.md # User documentation
|
||||
└── LICENSE # MIT License
|
||||
```
|
||||
|
||||
## Config Options
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| address | 0.0.0.0 | Listen address |
|
||||
| port | 8086 | Listen port |
|
||||
| cert/key | - | TLS cert/key paths |
|
||||
| nohttps | false | Disable HTTPS |
|
||||
| location | /archive | API endpoint path |
|
||||
| user | - | Basic auth username (required) |
|
||||
| password | - | Basic auth password (required) |
|
||||
| log | stderr | Log output |
|
||||
| loglevel | info | Log verbosity (debug/info/warn/error) |
|
||||
| directories | - | Directories to backup (required) |
|
||||
| backup_timeout | 60 | Stream timeout in minutes (1-1440) |
|
||||
| compression_level | 9 | Gzip level (1-9) |
|
||||
| exclude_patterns | [] | Regex patterns to exclude from backup |
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### Global Config
|
||||
Intentional design - uses global `C` variable:
|
||||
```go
|
||||
var C Config // internal/backer/globals.go
|
||||
```
|
||||
|
||||
### Config Parsing
|
||||
HJSON requires double conversion (hjson → map → json → struct):
|
||||
```go
|
||||
hjson.Unmarshal(buf, &tmp) // tmp is map[string]any
|
||||
json.Marshal(tmp)
|
||||
json.Unmarshal(buf, &C)
|
||||
```
|
||||
|
||||
### Stream Cancellation
|
||||
Context-aware write and copy functions:
|
||||
```go
|
||||
func writeWithContext(ctx context.Context, fn func() error) error
|
||||
func copyWithContext(ctx context.Context, dst io.Writer, src io.Reader) error
|
||||
```
|
||||
|
||||
### Pipe Error Handling
|
||||
Uses `pipeErr` tracking to avoid noisy errors when reader closes early.
|
||||
Skips files that can't be read (logs warning, continues backup).
|
||||
|
||||
### Content-Disposition Filename
|
||||
Backup filenames include timestamp: `backup-20060102-150405.tar.gz`
|
||||
|
||||
### Special File Types
|
||||
- **Device files** (character/block) are included with correct major/minor numbers.
|
||||
- **Sockets** are skipped (no entry created).
|
||||
- **Symlinks** are stored as symlinks (target path stored).
|
||||
- **Named pipes** and other special files are included (tar header created).
|
||||
- **Regular files** are compressed; other file types have no data section.
|
||||
|
||||
### Error Recovery
|
||||
- `writeWithContext` and `copyWithContext` recover from panics in callbacks and return an error.
|
||||
|
||||
### Performance Notes
|
||||
- Exclude patterns are precompiled after config load for faster matching.
|
||||
- Compression level is configurable (1-9); default 9 uses more CPU.
|
||||
- Large directory walks use `filepath.WalkDir` (efficient).
|
||||
|
||||
## Coding Style & Conventions
|
||||
|
||||
### Function Naming
|
||||
```go
|
||||
// Exported (public): VerbNoun pattern
|
||||
NewServer()
|
||||
LoadConfig()
|
||||
CreateTarGzStream()
|
||||
GetFilesFromDirectories()
|
||||
|
||||
// Unexported (private): verbNoun or isNoun pattern
|
||||
writeWithContext()
|
||||
copyWithContext()
|
||||
isPipeClosedError() // predicate helper
|
||||
defaultConfigPath()
|
||||
```
|
||||
|
||||
### JSON Tags
|
||||
Struct fields use PascalCase, json tags use snake_case:
|
||||
```go
|
||||
BackupTimeout int `json:"backup_timeout"`
|
||||
CompressionLevel int `json:"compression_level"`
|
||||
```
|
||||
|
||||
### Error Messages
|
||||
- Start with capital letter (ST1005 disabled)
|
||||
- Use past tense: `"Failed to..."`, `"Unable to..."`
|
||||
- Include context: `fmt.Errorf("Failed to close %s: %w", name, err)`
|
||||
- Use `%v` for errors (not `%s`)
|
||||
|
||||
### Logging
|
||||
- Past tense: `"Failed to close gzip writer: %v", err`
|
||||
- Context included: `"Skipping %s: %v", filename, err`
|
||||
- Appropriate level: Warn for recoverable, Error for issues, Fatal for startup failures
|
||||
|
||||
### Config Validation Pattern
|
||||
For each config option: set default first, then immediately validate:
|
||||
```go
|
||||
// Port: set default, then validate range.
|
||||
if C.Port == 0 {
|
||||
C.Port = 8086
|
||||
log.Warnf("Config option port is not set, fallback to %d", C.Port)
|
||||
}
|
||||
|
||||
if C.Port < 1 || C.Port > 65535 {
|
||||
return fmt.Errorf("Port must be between 1 and 65535, got %d", C.Port)
|
||||
}
|
||||
```
|
||||
|
||||
### Go Formatting
|
||||
- Tabs for indentation
|
||||
- Vim modeline at EOF: `/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */`
|
||||
|
||||
### Linter Suppressions (intentional)
|
||||
| Rule | Reason |
|
||||
|------|--------|
|
||||
| ST1005 | Capitalized error messages for readability |
|
||||
| QF1001 | De Morgan's law disabled for auth condition clarity |
|
||||
| revive (error strings) | Same reason as ST1005 |
|
||||
|
||||
### Comment Style
|
||||
- Profanity allowed (pet project)
|
||||
- Use `//nolint:` only when absolutely necessary
|
||||
|
||||
## Testing
|
||||
```bash
|
||||
go test ./... # Run tests
|
||||
go test -v -run TestName ./... # Run specific test
|
||||
golangci-lint run ./... # Lint
|
||||
```
|
||||
|
||||
Coverage: ~69% on internal/backer (main and log packages not covered by design)
|
||||
|
||||
### Test Conventions
|
||||
- Reset global config: `C = Config{}`
|
||||
- Use `t.TempDir()` for temporary files
|
||||
- Helper function `writeTempConfig()` for config tests
|
||||
|
||||
## Build
|
||||
```bash
|
||||
make # Build binary (version from git or "dev")
|
||||
make build VERSION=0.1.0 # Build with specific version
|
||||
make clean # Remove binary
|
||||
make test # Run tests
|
||||
make upgrade # Update dependencies
|
||||
make help # Show targets
|
||||
```
|
||||
|
||||
### Versioning
|
||||
Version is injected at build time via ldflags:
|
||||
```go
|
||||
var version = "dev" // overridden by: -X main.version=$(VERSION)
|
||||
```
|
||||
|
||||
## Known Issues / TODO
|
||||
See TODO.org for current backlog:
|
||||
- Consider different compression algorithms (bz2, xz, lzo, lz4)
|
||||
- Consider configurable filename prefix for content-disposition
|
||||
24
test_data/example.crt
Normal file
24
test_data/example.crt
Normal file
@@ -0,0 +1,24 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIID9zCCAt+gAwIBAgIUMYNatgQoPFvsrlyRU03P7mcWtA8wDQYJKoZIhvcNAQEL
|
||||
BQAwgYkxCzAJBgNVBAYTAlJVMQ8wDQYDVQQIDAZNb3Njb3cxDzANBgNVBAcMBk1v
|
||||
c2NvdzEYMBYGA1UECgwPSG9tZSBTd2VldCBIb21lMQswCQYDVQQLDAJJVDESMBAG
|
||||
A1UEAwwJbG9jYWxob3N0MR0wGwYJKoZIhvcNAQkBFg5yb290QGxvY2FsaG9zdDAg
|
||||
Fw0yNjAzMjQwODU3NDJaGA8yMTI2MDIyODA4NTc0MlowgYkxCzAJBgNVBAYTAlJV
|
||||
MQ8wDQYDVQQIDAZNb3Njb3cxDzANBgNVBAcMBk1vc2NvdzEYMBYGA1UECgwPSG9t
|
||||
ZSBTd2VldCBIb21lMQswCQYDVQQLDAJJVDESMBAGA1UEAwwJbG9jYWxob3N0MR0w
|
||||
GwYJKoZIhvcNAQkBFg5yb290QGxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQAD
|
||||
ggEPADCCAQoCggEBANxz73cHg+Hl/o+t023tafuYn9KgJGhwnjhtx1UYdZtE9lA8
|
||||
Y3Ae3VCgTZIdsTq7F0DmCYO/Bt8q7E+lAInQMzSwANJ2h97nKOQD7SlZFPZb7Pka
|
||||
jjM7u/MOv944exyfu36b3qCk5YkqHOBTO+pJx3RLcXWhkZKn3DJafrZCa9N3Wwj1
|
||||
XTCtfbnnSWD3I/7F9JOLNJS6ZfPzDjgyPfKRKN5rs9ZVZq2t6OOlrC6TwhBOuC9U
|
||||
GXnCM+rBM4c96EBv50ep9JbbrppfXB9jWxMgssAVIpvLaMRvVkNbWO+W3zsRDr5H
|
||||
SyeVikz/kQ9EfCkWg8YiUU5VUSRzPKbR5S4H4uUCAwEAAaNTMFEwHQYDVR0OBBYE
|
||||
FCiFjFTNly/CXi8BPhOymoOwBk6mMB8GA1UdIwQYMBaAFCiFjFTNly/CXi8BPhOy
|
||||
moOwBk6mMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAIy0z5SY
|
||||
AC4x46J/EGXe4OUmVkwyHkcgW7JNjcOrN1N9ijnuhNVAe2afvGTV0SV2yInY5pXB
|
||||
LajbHoFBRAikvEmtPaCQXc7rHN51ywDLFsie/6Xr3if59I5MH+YukWZzwCN8PIFM
|
||||
+PDF3cLT+Kae+66LLpUTn5X8mGQ7wx4qrpr8sxXj621+hz1FR18sSUZt9/OPJoQM
|
||||
cIoaWDp3LfHEdQ8zfe/9V/1g4+CkpDstUDgFsS0LhFioY+9jtneglEMSu8kN2l3T
|
||||
Lnzpt1tDnlxQm80EfA4yK5AmpeOq8XpBVxEDlvg7GdB1MTvUsezM8CoUCUrzRrL8
|
||||
ZMrJeUrD84DrZGk=
|
||||
-----END CERTIFICATE-----
|
||||
28
test_data/example.key
Normal file
28
test_data/example.key
Normal file
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDcc+93B4Ph5f6P
|
||||
rdNt7Wn7mJ/SoCRocJ44bcdVGHWbRPZQPGNwHt1QoE2SHbE6uxdA5gmDvwbfKuxP
|
||||
pQCJ0DM0sADSdofe5yjkA+0pWRT2W+z5Go4zO7vzDr/eOHscn7t+m96gpOWJKhzg
|
||||
UzvqScd0S3F1oZGSp9wyWn62QmvTd1sI9V0wrX2550lg9yP+xfSTizSUumXz8w44
|
||||
Mj3ykSjea7PWVWatrejjpawuk8IQTrgvVBl5wjPqwTOHPehAb+dHqfSW266aX1wf
|
||||
Y1sTILLAFSKby2jEb1ZDW1jvlt87EQ6+R0snlYpM/5EPRHwpFoPGIlFOVVEkczym
|
||||
0eUuB+LlAgMBAAECggEALuI6JIiU3+tDuOdJLpj9/+u827TCoXKH96zDJoIR6vBe
|
||||
6P/6ztrFixLYhHFronetEZiKn/n1mmscwDBxfZ2pcjRXLGtYaIulSt7Z5oADQQSa
|
||||
UHz5DoTO6zlxDS/aYONIovu/vE7tYP8zjnKtLJ29vSoZSmDO3o9z+UJ9LcJQ/7L+
|
||||
uU+HLGDiiWlCIfVPaF73cVF+sX5GCpjzhS4A2fxn+O0X1wBXmP2hDC1cb3kP8aL+
|
||||
RuBniiEKuWmV8l1SLfVBbdOwla/BCv0TyiWiPbIluDmIPst6IScAbjrx4hyh0Wyc
|
||||
P04ZQDom2NyqHZqJD4qeih2I/4Ly9MlQ8Lp2DtI8GQKBgQDx/rzwN3Zv7KWJwNVJ
|
||||
B/N4IiAxx2y294+hnceJgfS9XLAutu/ToomhmAdi0ZUjC508NSQLWO6HlcmU69hR
|
||||
AydBR1aMd32RpAmO3QNjqQcZjsCXhWrChnInNOEW6Xc5azCo1Gjpt3CgDYR1mBUm
|
||||
i/EhpP8mbdfj/d34sSxV79YhjQKBgQDpNgpVQPT87QgVVTsp2Bm5C6sfQNW8FY5x
|
||||
bUPHeSkwNyCo4wUUW4lC/LkPP1AeaACW8Dvd8NcvHPRvdqS0c8IPFYrkQZRwjDl+
|
||||
F3vdLgW3nuC8zUolaTNQyLPBQiJZV80U5rLTcDziEwEJvsvq/o8JHeYUf/90SpLT
|
||||
3y0Qcp00uQKBgHSb57dpQm0Uf4Atp5kVd8C/0PuruTtBd7jNKsWcFa7Z5xvbkx1M
|
||||
CT4Q4B5CF7dXdWD5ynCdXfN/vi9ApnADMPtHwPDW828muGRbI7L2VDGAlFVvOM6b
|
||||
so4p8Pz1azoD6OKx2AwnFOJLITT5H9f/PyaTN0/xRkLapgeceFzAwVJRAoGARN3x
|
||||
dTWGYv44CdyuaO/owK1Cbis+MpmHA9XJUyljUVqmKVlI0bcqNH7ewzLmHOSHcAsn
|
||||
EqYhVUdOo2+8uZEJeFzbNAZtqEKwY5zZtfn2NEAVi4tv3tZPEgcClgpKjGmSJR7v
|
||||
JeMaB4SnJNKT8yQS5xVOMTWPIS84Zpqb+yRtafkCgYA+b4JSnBUtDsHIaLbP6Y4N
|
||||
yd7SneucwuJBF9d8n+fzWcDMDLi/JYtSdgSlMPMruv0CeXNTZyEqQUAKzbpd1/eQ
|
||||
0hXmPqoyq8mV+qwo5avEddPbkKh7oPCaqsM7VfB1jwFXWZCiOoXk0mOQ3+Miv6hJ
|
||||
t2A0YvyV9XdFvaxGMg0Ajg==
|
||||
-----END PRIVATE KEY-----
|
||||
1
test_data/test1/bar/goodbye.txt/text.txt
Normal file
1
test_data/test1/bar/goodbye.txt/text.txt
Normal file
@@ -0,0 +1 @@
|
||||
Fucked up beyond recognotion.
|
||||
0
test_data/test1/bar/hello.txt
Normal file
0
test_data/test1/bar/hello.txt
Normal file
2
test_data/test1/foo/hello_breakfast.txt
Normal file
2
test_data/test1/foo/hello_breakfast.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
My dear Tweety,
|
||||
I'd like to greet you as my breakfast.
|
||||
1
test_data/test1/foo/mydir/myfile.txt
Normal file
1
test_data/test1/foo/mydir/myfile.txt
Normal file
@@ -0,0 +1 @@
|
||||
This is MY file!
|
||||
0
test_data/test1/foo/some_text.txt
Normal file
0
test_data/test1/foo/some_text.txt
Normal file
43
test_data/test_config.json
Normal file
43
test_data/test_config.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
/* Listen on other addresses is pointless. */
|
||||
"address": "127.0.0.1",
|
||||
|
||||
/* By default listen on port 8086, like first intel commercially successful cpu model. */
|
||||
"port": 8086,
|
||||
|
||||
/* Path location of certificate file */
|
||||
"cert": "../../test_data/example.crt",
|
||||
|
||||
/* Path location of certificate key */
|
||||
"key": "../../test_data/example.key",
|
||||
|
||||
/* We really want to use https for backup archive tranferring. */
|
||||
"nohttps": false,
|
||||
|
||||
/* Which location we should query by our client curl, for example. */
|
||||
"location": "/archive",
|
||||
|
||||
/* User for basic auth. */
|
||||
"user": "JohnnyGoode",
|
||||
|
||||
/* Password for basic auth. */
|
||||
"password": "SharpShooter",
|
||||
|
||||
/* Where to store logs. */
|
||||
"log": "/var/log/backer.log",
|
||||
|
||||
/* Verbosity of logs */
|
||||
"loglevel": "info",
|
||||
|
||||
/* Directories to backup */
|
||||
"directories": [
|
||||
"../../test_data/test1/foo",
|
||||
"../../test_data/test1/bar"
|
||||
],
|
||||
|
||||
/* Backup streaming timeout in minutes. */
|
||||
"backup_timeout": 60,
|
||||
|
||||
/* Gzip compression level (1-9, where 9 is best compression). */
|
||||
"compression_level": 6
|
||||
}
|
||||
Reference in New Issue
Block a user