Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
7 changes: 6 additions & 1 deletion cmd/nobackups/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
Expand Down
19 changes: 18 additions & 1 deletion docs/configuration/destinations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ Names may contain letters, digits, `.`, `_` and `-`.
```
</Tab>
<Tab title="Alarik / Garage">
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:
Expand Down Expand Up @@ -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

<AccordionGroup>
<Accordion title="ETag mismatch for part 1" icon="triangle-exclamation">
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.
</Accordion>
<Accordion title="See exactly what the server returns" icon="magnifying-glass">
Set `NOBACKUPS_S3_TRACE=1` to print every S3 request and response header (signatures are redacted):

```bash
sudo NOBACKUPS_S3_TRACE=1 nobackups check
```
</Accordion>
</AccordionGroup>
2 changes: 1 addition & 1 deletion docs/guides/operations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
10 changes: 5 additions & 5 deletions docs/installation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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).

<Tip>
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`.
</Tip>

## Other ways to install
Expand Down Expand Up @@ -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 |
|---|---|
Expand All @@ -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

Expand Down Expand Up @@ -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
```

Expand Down
2 changes: 1 addition & 1 deletion docs/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion install.sh
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
85 changes: 79 additions & 6 deletions internal/storage/s3.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package storage

import (
"bytes"
"context"
"crypto/md5"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"fmt"
"io"
"os"
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading