From 2ea59b678808977c6d31eafb5885a31daa2202a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 01:05:09 +0000 Subject: [PATCH 1/2] Use 'sh install.sh' in docs and restore executable bit './install.sh' fails when the executable bit is lost (e.g. commits from Windows); 'sh install.sh' always works. Also document that installer variables must come after sudo, which strips them otherwise. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TXNwZBkaVRn9LobyrUJcGA --- README.md | 4 ++-- docs/guides/operations.mdx | 2 +- docs/installation.mdx | 10 +++++----- docs/quickstart.mdx | 2 +- install.sh | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) mode change 100644 => 100755 install.sh diff --git a/README.md b/README.md index 45c1280..ce54b90 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,10 @@ Everything happens **on the server**, so it doesn't matter whether your own mach ```sh git clone https://github.com/CodeMeAPixel/NoBackups cd NoBackups -sudo ./install.sh +sudo sh install.sh ``` -If the server has Go 1.24+, the installer builds from source. Otherwise it downloads the release binary for the server's CPU and verifies its checksum. Upgrading is `git pull && sudo ./install.sh`. +If the server has Go 1.24+, the installer builds from source. Otherwise it downloads the release binary for the server's CPU and verifies its checksum. Upgrading is `git pull && sudo sh install.sh`. Other options: diff --git a/docs/guides/operations.mdx b/docs/guides/operations.mdx index b3da335..e288218 100644 --- a/docs/guides/operations.mdx +++ b/docs/guides/operations.mdx @@ -73,7 +73,7 @@ Manual runs and scheduled runs share the same lock. If the daemon is mid-backup Run the installer again. It keeps your config and restarts the running service on the new version: ```bash -cd NoBackups && git pull && sudo ./install.sh +cd NoBackups && git pull && sudo sh install.sh nobackups version ``` diff --git a/docs/installation.mdx b/docs/installation.mdx index 533e9d2..e08490c 100644 --- a/docs/installation.mdx +++ b/docs/installation.mdx @@ -11,7 +11,7 @@ All you need is an SSH session to the server: PowerShell or Windows Terminal (`s ```bash git clone https://github.com/CodeMeAPixel/NoBackups cd NoBackups -sudo ./install.sh +sudo sh install.sh ``` The installer picks the best way to get a binary: @@ -22,7 +22,7 @@ The installer picks the best way to get a binary: Then it installs the binary, config and systemd unit, as described in [What gets installed](#what-gets-installed). - If `./install.sh` says "permission denied", run `sudo sh ./install.sh` instead. This can happen when the repository was last committed from Windows, which doesn't keep the executable bit. + Already logged in as root? Drop the `sudo`: `sh install.sh`. ## Other ways to install @@ -69,7 +69,7 @@ Then it installs the binary, config and systemd unit, as described in [What gets ## Installer options -Set these as environment variables in front of `sudo ./install.sh` (or after `sudo` with the one-liner, e.g. `curl ... | sudo NOBACKUPS_VERSION=v0.2.0 sh`): +Put them **after** `sudo`, because `sudo` drops variables set before it: `sudo NOBACKUPS_VERSION=v0.2.0 sh install.sh`, or with the one-liner `curl ... | sudo NOBACKUPS_VERSION=v0.2.0 sh`. | Variable | Effect | |---|---| @@ -78,7 +78,7 @@ Set these as environment variables in front of `sudo ./install.sh` (or after `su | `NOBACKUPS_SOURCE=build` | Always build from source; fail if Go isn't available | | `NOBACKUPS_DOWNLOAD_URL=https://mirror.example/nobackups` | Download release files from a mirror (it must serve the binaries and `SHA256SUMS`) | -You can also install a binary you already have: `sudo ./install.sh ./nobackups-linux-amd64`. +You can also install a binary you already have: `sudo sh install.sh ./nobackups-linux-amd64`. ## What gets installed @@ -106,7 +106,7 @@ sudo systemctl enable --now nobackups Run the same thing again: ```bash -cd NoBackups && git pull && sudo ./install.sh # clone +cd NoBackups && git pull && sudo sh install.sh # clone curl -fsSL https://raw.githubusercontent.com/CodeMeAPixel/NoBackups/master/install.sh | sudo sh # one-liner ``` diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index d9bb5c5..3fdad8e 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -18,7 +18,7 @@ icon: "rocket" ```bash git clone https://github.com/CodeMeAPixel/NoBackups cd NoBackups - sudo ./install.sh + sudo sh install.sh ``` It builds from source if Go is installed, otherwise it downloads the release binary for your server's CPU. See [Installation](/installation) for a no-clone one-liner and offline options. diff --git a/install.sh b/install.sh old mode 100644 new mode 100755 index 2feaa8e..75f4926 --- a/install.sh +++ b/install.sh @@ -8,7 +8,7 @@ SOURCE=${NOBACKUPS_SOURCE:-auto} say() { printf '==> %s\n' "$*"; } die() { printf 'error: %s\n' "$*" >&2; exit 1; } -[ "$(id -u)" -eq 0 ] || die "run as root, e.g. sudo sh $0" +[ "$(id -u)" -eq 0 ] || die "run as root, e.g. sudo sh install.sh" [ "$(uname -s)" = Linux ] || die "NoBackups runs on Linux servers" TMP=$(mktemp -d) From 0f6f18bf2fa4d778184799f5de310f0055910ec3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 01:21:51 +0000 Subject: [PATCH 2/2] Fix multipart uploads through ETag-rewriting proxies S3 destinations behind a proxy that weakens ETags (e.g. Cloudflare in front of Alarik) failed every upload with 'ETag mismatch for part 1': minio-go sent back W/"..." values the server could not match. - Upload via minio Core: objects up to one part use a single PUT; larger ones are sent as multipart with ETags normalised (W/ stripped) and quoted like the AWS SDKs, plus Content-MD5 per part - 'check' now also performs a real multipart upload - NOBACKUPS_S3_TRACE=1 prints S3 request/response headers - Tests model Alarik's ETag comparison behind a weakening proxy - Docs: troubleshooting for the error and proxied endpoints Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TXNwZBkaVRn9LobyrUJcGA --- cmd/nobackups/main.go | 7 +- docs/configuration/destinations.mdx | 19 +++- internal/storage/s3.go | 85 +++++++++++++-- internal/storage/s3_test.go | 162 ++++++++++++++++++++++++++++ 4 files changed, 265 insertions(+), 8 deletions(-) create mode 100644 internal/storage/s3_test.go diff --git a/cmd/nobackups/main.go b/cmd/nobackups/main.go index ce72529..8ea5b9b 100644 --- a/cmd/nobackups/main.go +++ b/cmd/nobackups/main.go @@ -271,7 +271,7 @@ func cmdCheck(ctx context.Context, r *backup.Runner) error { } func checkDestination(ctx context.Context, r *backup.Runner, name string) error { - ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) defer cancel() b, err := r.Backend(name) if err != nil { @@ -286,6 +286,11 @@ func checkDestination(ctx context.Context, r *backup.Runner, name string) error if err := b.Put(ctx, key, strings.NewReader("nobackups write test\n")); err != nil { return fmt.Errorf("write: %w", err) } + if s3, ok := b.(*storage.S3); ok { + if err := s3.CheckMultipart(ctx, key+"-multipart"); err != nil { + return fmt.Errorf("multipart write: %w", err) + } + } if _, err := b.List(ctx, ""); err != nil { return fmt.Errorf("list: %w", err) } diff --git a/docs/configuration/destinations.mdx b/docs/configuration/destinations.mdx index f3f320b..6a4267e 100644 --- a/docs/configuration/destinations.mdx +++ b/docs/configuration/destinations.mdx @@ -117,7 +117,7 @@ Names may contain letters, digits, `.`, `_` and `-`. ``` - Other self-hosted S3-compatible stores work the same way as RustFS. Use the endpoint and region your server is configured with, and `path_style: true` unless you've set up virtual-host-style bucket DNS. + [Alarik](https://github.com/achtungsoftware/alarik) and other self-hosted S3-compatible stores work the same way as RustFS. If you put the endpoint behind Cloudflare, see [Troubleshooting](#troubleshooting). Use the endpoint and region your server is configured with, and `path_style: true` unless you've set up virtual-host-style bucket DNS. ```yaml alarik: @@ -190,3 +190,20 @@ For each destination, this checks the bucket exists (S3), then writes, lists and ✓ hetzner ok ✗ rustfs write: The Access Key Id you provided does not exist in our records. ``` + +## Troubleshooting + + + + Usually means a CDN or reverse proxy sits between NoBackups and the storage server and rewrites the `ETag` header. Cloudflare's orange-cloud proxy is the usual suspect: it can turn `"abc"` into the weak form `W/"abc"`, which some servers (e.g. Alarik) then fail to match. + + NoBackups handles weakened ETags, so updating fixes the error. You're still better off pointing `endpoint` at a hostname that **isn't** proxied, such as a DNS-only (grey-cloud) record or the server's own address. Proxying backups through a CDN adds latency, and Cloudflare limits request bodies to 100 MB on most plans, so keep `part_size_mb` below that if you must proxy. + + + Set `NOBACKUPS_S3_TRACE=1` to print every S3 request and response header (signatures are redacted): + + ```bash + sudo NOBACKUPS_S3_TRACE=1 nobackups check + ``` + + diff --git a/internal/storage/s3.go b/internal/storage/s3.go index 86f34ad..eaafeda 100644 --- a/internal/storage/s3.go +++ b/internal/storage/s3.go @@ -1,9 +1,12 @@ package storage import ( + "bytes" "context" + "crypto/md5" "crypto/tls" "crypto/x509" + "encoding/base64" "fmt" "io" "os" @@ -62,6 +65,9 @@ func NewS3(d *config.Destination) (*S3, error) { if err != nil { return nil, fmt.Errorf("destination %s: %w", d.Name, err) } + if os.Getenv("NOBACKUPS_S3_TRACE") != "" { + client.TraceOn(os.Stderr) + } return &S3{ name: d.Name, client: client, @@ -75,12 +81,69 @@ func NewS3(d *config.Destination) (*S3, error) { func (s *S3) Name() string { return s.name } func (s *S3) Put(ctx context.Context, key string, r io.Reader) error { - _, err := s.client.PutObject(ctx, s.bucket, joinKey(s.prefix, key), r, -1, minio.PutObjectOptions{ - ContentType: "application/octet-stream", - PartSize: s.partSize, - StorageClass: s.class, - }) - return err + return s.upload(ctx, joinKey(s.prefix, key), r, int64(s.partSize)) +} + +func (s *S3) upload(ctx context.Context, object string, r io.Reader, partSize int64) error { + core := minio.Core{Client: s.client} + opts := minio.PutObjectOptions{ContentType: "application/octet-stream", StorageClass: s.class} + buf := make([]byte, partSize) + + n, err := io.ReadFull(r, buf) + if err == io.EOF || err == io.ErrUnexpectedEOF { + _, err = core.PutObject(ctx, s.bucket, object, bytes.NewReader(buf[:n]), int64(n), md5Base64(buf[:n]), "", opts) + return err + } + if err != nil { + return err + } + + uploadID, err := core.NewMultipartUpload(ctx, s.bucket, object, opts) + if err != nil { + return err + } + parts, err := s.uploadParts(ctx, core, object, uploadID, r, buf, n) + if err == nil { + _, err = core.CompleteMultipartUpload(ctx, s.bucket, object, uploadID, parts, opts) + } + if err != nil { + _ = core.AbortMultipartUpload(context.WithoutCancel(ctx), s.bucket, object, uploadID) + return err + } + return nil +} + +func (s *S3) uploadParts(ctx context.Context, core minio.Core, object, uploadID string, r io.Reader, buf []byte, n int) ([]minio.CompletePart, error) { + var parts []minio.CompletePart + for num := 1; n > 0; num++ { + if num > maxParts { + return nil, fmt.Errorf("backup is larger than %d parts of %d MB; raise part_size_mb", maxParts, len(buf)>>20) + } + data := buf[:n] + part, err := core.PutObjectPart(ctx, s.bucket, object, uploadID, num, bytes.NewReader(data), int64(n), + minio.PutObjectPartOptions{Md5Base64: md5Base64(data)}) + if err != nil { + return nil, fmt.Errorf("upload part %d: %w", num, err) + } + parts = append(parts, minio.CompletePart{PartNumber: num, ETag: quoteETag(part.ETag)}) + + if n, err = io.ReadFull(r, buf); err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { + return nil, err + } + } + return parts, nil +} + +const maxParts = 10000 + +func quoteETag(etag string) string { + etag = strings.TrimPrefix(etag, "W/") + return `"` + strings.Trim(etag, `"`) + `"` +} + +func md5Base64(b []byte) string { + sum := md5.Sum(b) + return base64.StdEncoding.EncodeToString(sum[:]) } func (s *S3) Get(ctx context.Context, key string) (io.ReadCloser, error) { @@ -115,6 +178,16 @@ func (s *S3) Delete(ctx context.Context, key string) error { return s.client.RemoveObject(ctx, s.bucket, joinKey(s.prefix, key), minio.RemoveObjectOptions{}) } +func (s *S3) CheckMultipart(ctx context.Context, key string) error { + const part = 5 << 20 + data := make([]byte, part+1024) + object := joinKey(s.prefix, key) + if err := s.upload(ctx, object, bytes.NewReader(data), part); err != nil { + return err + } + return s.client.RemoveObject(ctx, s.bucket, object, minio.RemoveObjectOptions{}) +} + func (s *S3) CheckBucket(ctx context.Context) error { ok, err := s.client.BucketExists(ctx, s.bucket) if err != nil { diff --git a/internal/storage/s3_test.go b/internal/storage/s3_test.go new file mode 100644 index 0000000..a8e1154 --- /dev/null +++ b/internal/storage/s3_test.go @@ -0,0 +1,162 @@ +package storage + +import ( + "bytes" + "context" + "crypto/md5" + "encoding/hex" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/minio/minio-go/v7" + + "github.com/codemeapixel/nobackups/internal/config" +) + +type fakeAlarik struct { + mu sync.Mutex + weakETags bool + parts map[int]string + partData map[int][]byte + objects map[string][]byte + completes int +} + +func newFakeAlarik(weak bool) *fakeAlarik { + return &fakeAlarik{weakETags: weak, parts: map[int]string{}, partData: map[int][]byte{}, objects: map[string][]byte{}} +} + +func (f *fakeAlarik) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + q := r.URL.Query() + body, _ := io.ReadAll(r.Body) + etagHeader := func(sum string) { + if f.weakETags { + w.Header().Set("ETag", `W/"`+sum+`"`) + } else { + w.Header().Set("ETag", `"`+sum+`"`) + } + } + switch { + case r.Method == http.MethodPost && q.Has("uploads"): + fmt.Fprint(w, `u1`) + case r.Method == http.MethodPut && q.Has("partNumber"): + var n int + fmt.Sscan(q.Get("partNumber"), &n) + sum := md5.Sum(body) + f.parts[n] = hex.EncodeToString(sum[:]) + f.partData[n] = body + etagHeader(hex.EncodeToString(sum[:])) + case r.Method == http.MethodPost && q.Has("uploadId"): + var req struct { + Parts []struct { + PartNumber int + ETag string + } `xml:"Part"` + } + xml.Unmarshal(body, &req) + var all []byte + for _, p := range req.Parts { + if f.parts[p.PartNumber] != strings.ReplaceAll(strings.TrimSpace(p.ETag), `"`, "") { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, `InvalidPartETag mismatch for part %d`, p.PartNumber) + return + } + all = append(all, f.partData[p.PartNumber]...) + } + f.objects[r.URL.Path] = all + f.completes++ + fmt.Fprint(w, `bktk"x-2"`) + case r.Method == http.MethodDelete: + delete(f.objects, r.URL.Path) + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodPut: + f.objects[r.URL.Path] = body + sum := md5.Sum(body) + etagHeader(hex.EncodeToString(sum[:])) + default: + w.WriteHeader(http.StatusNotImplemented) + } +} + +func testS3(t *testing.T, srv *httptest.Server) *S3 { + t.Helper() + tr := true + s, err := NewS3(&config.Destination{ + Name: "alarik", Type: "s3", Endpoint: strings.TrimPrefix(srv.URL, "https://"), UseSSL: &tr, InsecureSkipVerify: true, + Bucket: "bkt", AccessKeyID: "k", SecretAccessKey: "s", Region: "us-east-1", PathStyle: true, PartSizeMB: 5, + }) + if err != nil { + t.Fatal(err) + } + return s +} + +func TestUploadThroughETagWeakeningProxy(t *testing.T) { + for _, weak := range []bool{false, true} { + t.Run(fmt.Sprintf("weak=%v", weak), func(t *testing.T) { + fake := newFakeAlarik(weak) + srv := httptest.NewTLSServer(fake) + defer srv.Close() + s := testS3(t, srv) + ctx := context.Background() + + data := bytes.Repeat([]byte("0123456789"), 1_200_000) + if err := s.Put(ctx, "big", bytes.NewReader(data)); err != nil { + t.Fatal(err) + } + if fake.completes != 1 || len(fake.parts) != 3 { + t.Errorf("expected one 3-part upload, got %d completes, %d parts", fake.completes, len(fake.parts)) + } + if got := fake.objects["/bkt/big"]; !bytes.Equal(got, data) { + t.Errorf("stored %d bytes, want %d", len(got), len(data)) + } + + if err := s.Put(ctx, "small", strings.NewReader("tiny")); err != nil { + t.Fatal(err) + } + if fake.completes != 1 || string(fake.objects["/bkt/small"]) != "tiny" { + t.Error("small objects should use a single PUT") + } + + if err := s.CheckMultipart(ctx, "check"); err != nil { + t.Fatal(err) + } + if fake.completes != 2 { + t.Error("CheckMultipart must exercise a multipart upload") + } + }) + } +} + +func TestMinioUploaderFailsBehindETagWeakeningProxy(t *testing.T) { + srv := httptest.NewTLSServer(newFakeAlarik(true)) + defer srv.Close() + s := testS3(t, srv) + _, err := s.client.PutObject(context.Background(), "bkt", "x", bytes.NewReader(make([]byte, 6<<20)), -1, + minio.PutObjectOptions{PartSize: 5 << 20}) + if err == nil || !strings.Contains(err.Error(), "ETag mismatch for part 1") { + t.Fatalf("expected minio-go's own uploader to hit the weak-ETag mismatch, got %v", err) + } +} + +func TestUploadExactPartMultiple(t *testing.T) { + fake := newFakeAlarik(false) + srv := httptest.NewTLSServer(fake) + defer srv.Close() + s := testS3(t, srv) + data := make([]byte, 10<<20) + if err := s.Put(context.Background(), "exact", bytes.NewReader(data)); err != nil { + t.Fatal(err) + } + if len(fake.parts) != 2 || len(fake.objects["/bkt/exact"]) != len(data) { + t.Errorf("got %d parts, %d bytes", len(fake.parts), len(fake.objects["/bkt/exact"])) + } +}