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
64 changes: 64 additions & 0 deletions api/targets.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ type Target struct {
// S3/B2/… backend credentials stay out of the mover's environment. Do not
// also set AWS_* in env (env takes precedence over the file).
CredentialsFile string `yaml:"credentialsFile,omitempty" json:"credentialsFile,omitempty" jsonschema:"description=Host path to an AWS shared-credentials file; mounted into the mover and used via AWS_SHARED_CREDENTIALS_FILE (keeps S3 creds out of env)"`
// Files are host files mounted read-only into every mover of this target
// (SSH keys, known_hosts, rclone config, …). sabon sets no env for them;
// reference Target from env (e.g. RCLONE_CONFIG_X_KEY_FILE).
Files []TargetFile `yaml:"files,omitempty" json:"files,omitempty" jsonschema:"description=Host files mounted read-only into the mover (SSH key or known_hosts or rclone config); reference the target path from env"`
// Env are extra environment variables passed to the mover (e.g. S3/R2
// credentials). Values support ${ENV} expansion from sabon's environment.
Env map[string]string `yaml:"env,omitempty" json:"env,omitempty" jsonschema:"description=Extra environment for the mover (e.g. AWS_ACCESS_KEY_ID); values support ${ENV} expansion"`
Expand All @@ -67,6 +71,63 @@ type Target struct {
Prune string `yaml:"prune,omitempty" json:"prune,omitempty" jsonschema:"description=Optional cron for periodic restic prune (reclaim space); empty disables"`
}

// TargetFile is one host file mounted read-only into a target's movers.
type TargetFile struct {
// Source is the host path of the file.
Source string `yaml:"source" json:"source" jsonschema:"description=Host path of the file,required"`
// Target is the absolute path inside the mover.
Target string `yaml:"target" json:"target" jsonschema:"description=Absolute path inside the mover,required"`
}

// reservedMoverPaths are mover paths sabon mounts itself; keep in sync with the
// mount constants in internal/backup.
var reservedMoverPaths = []string{
"/data",
"/repo",
"/cache",
"/restore",
"/run/secrets/sabon-restic-password",
"/run/secrets/sabon-aws-credentials",
}

// ReservedMoverPaths returns a copy of the mover paths sabon mounts itself.
func ReservedMoverPaths() []string {
return append([]string(nil), reservedMoverPaths...)
}

func (t Target) validateFiles() error {
seen := map[string]bool{}
for i, f := range t.Files {
if f.Source == "" || f.Target == "" {
return fmt.Errorf("target %q: files[%d]: source and target are required", t.Name, i)
}
if !isCleanHostFile(f.Source) {
return fmt.Errorf("target %q: files[%d]: source %q must be a clean absolute path without ':'", t.Name, i, f.Source)
}
if strings.Contains(f.Target, ":") {
return fmt.Errorf("target %q: files[%d]: target %q must not contain ':'", t.Name, i, f.Target)
}
if !path.IsAbs(f.Target) {
return fmt.Errorf("target %q: files[%d]: target %q must be an absolute path", t.Name, i, f.Target)
}
dst := path.Clean(f.Target)
if dst == "/" {
return fmt.Errorf("target %q: files[%d]: target must not be /", t.Name, i)
}
for _, r := range reservedMoverPaths {
// A parent of a reserved path would shadow sabon's own mounts.
if dst == r || strings.HasPrefix(dst, r+"/") || strings.HasPrefix(r, dst+"/") {
return fmt.Errorf("target %q: files[%d]: target %q collides with reserved mover path %q", t.Name, i, f.Target, r)
}
}
if seen[dst] {
return fmt.Errorf("target %q: files[%d]: duplicate target %q", t.Name, i, f.Target)
}
seen[dst] = true
}
return nil
}

// Backend classifies the target.
func (t Target) Backend() Backend {
if t.Path != "" {
Expand Down Expand Up @@ -104,6 +165,9 @@ func (f *File) Validate() error {
return fmt.Errorf("target %q: %s %q must be a clean absolute path without ':'", t.Name, field, p)
}
}
if err := t.validateFiles(); err != nil {
return err
}
}
return nil
}
Expand Down
19 changes: 19 additions & 0 deletions api/targets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,25 @@ func TestValidate(t *testing.T) {
{"passwordFile colon", []Target{{Name: "x", Path: "/p", PasswordFile: "/etc/pw:rw"}}, true},
{"credentialsFile ok", []Target{{Name: "x", Repo: "s3:y/{app}", CredentialsFile: "/etc/sabon/aws"}}, false},
{"credentialsFile unclean", []Target{{Name: "x", Repo: "s3:y/{app}", CredentialsFile: "/etc//aws"}}, true},
{"files ok", []Target{{Name: "x", Repo: "rclone:r:{app}", Files: []TargetFile{{Source: "/etc/sabon/key", Target: "/secrets/key"}, {Source: "/etc/sabon/known_hosts", Target: "/secrets/known_hosts"}}}}, false},
{"files empty source", []Target{{Name: "x", Path: "/p", Files: []TargetFile{{Target: "/secrets/key"}}}}, true},
{"files empty target", []Target{{Name: "x", Path: "/p", Files: []TargetFile{{Source: "/k"}}}}, true},
{"files relative target", []Target{{Name: "x", Path: "/p", Files: []TargetFile{{Source: "/k", Target: "secrets/key"}}}}, true},
{"files colon in source", []Target{{Name: "x", Path: "/p", Files: []TargetFile{{Source: "/k:rw", Target: "/secrets/key"}}}}, true},
{"files colon in target", []Target{{Name: "x", Path: "/p", Files: []TargetFile{{Source: "/k", Target: "/secrets/key:rw"}}}}, true},
{"files duplicate target", []Target{{Name: "x", Path: "/p", Files: []TargetFile{{Source: "/a", Target: "/secrets/key"}, {Source: "/b", Target: "/secrets//key/"}}}}, true},
{"files reserved exact", []Target{{Name: "x", Path: "/p", Files: []TargetFile{{Source: "/k", Target: "/repo"}}}}, true},
{"files reserved nested", []Target{{Name: "x", Path: "/p", Files: []TargetFile{{Source: "/k", Target: "/data/app/key"}}}}, true},
{"files reserved via dotdot", []Target{{Name: "x", Path: "/p", Files: []TargetFile{{Source: "/k", Target: "/secrets/../cache/key"}}}}, true},
{"files reserved secret", []Target{{Name: "x", Path: "/p", Files: []TargetFile{{Source: "/k", Target: "/run/secrets/sabon-restic-password"}}}}, true},
{"files reserved root", []Target{{Name: "x", Path: "/p", Files: []TargetFile{{Source: "/k", Target: "/"}}}}, true},
{"files relative source", []Target{{Name: "x", Path: "/p", Files: []TargetFile{{Source: "relative.key", Target: "/secrets/key"}}}}, true},
{"files source with dotdot", []Target{{Name: "x", Path: "/p", Files: []TargetFile{{Source: "/etc/link/../key", Target: "/secrets/key"}}}}, true},
{"files source double slash", []Target{{Name: "x", Path: "/p", Files: []TargetFile{{Source: "/etc//key", Target: "/secrets/key"}}}}, true},
{"files parent of reserved", []Target{{Name: "x", Path: "/p", Files: []TargetFile{{Source: "/k", Target: "/run/secrets"}}}}, true},
{"files grandparent of reserved", []Target{{Name: "x", Path: "/p", Files: []TargetFile{{Source: "/k", Target: "/run/"}}}}, true},
{"files sibling under /run ok", []Target{{Name: "x", Path: "/p", Files: []TargetFile{{Source: "/k", Target: "/run/secrets/rclone-key"}}}}, false},
{"files prefix lookalike ok", []Target{{Name: "x", Path: "/p", Files: []TargetFile{{Source: "/k", Target: "/database/key"}}}}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down
28 changes: 27 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,21 @@ Host path to a file holding the repo password. Mounted **read-only** into the mo

Host path to an AWS shared-credentials file (`[default]` with `aws_access_key_id`/`aws_secret_access_key`). Mounted **read-only** and exposed via `AWS_SHARED_CREDENTIALS_FILE` so S3/B2/… creds stay out of the mover's environment.

#### `files`

List of host files mounted **read-only** into every mover of this target, each as `{source, target}`: `source` is the host path, `target` the absolute path inside the mover. Use it for backend secrets and config that restic or the backend reads from a file (CA certificates, cloud key files, …). sabon sets no environment for these files; reference `target` yourself from [`env`](#env) or [`resticArgs`](#resticargs):

```yaml
- name: rest
repo: "rest:https://backup.example.internal:8000/{app}"
files:
- source: /etc/sabon/internal-ca.pem
target: /secrets/ca.pem
resticArgs: ["--cacert=/secrets/ca.pem"]
```

Every `source` must be a clean absolute path without `:` (no `.`, `..`, `//` or trailing `/`) to an existing regular file, checked before each run. A `target` must be absolute, unique within the target, must not contain `:`, and must not overlap a path sabon mounts itself (`/data`, `/repo`, `/cache`, `/restore`, `/run/secrets/sabon-*`): neither the path itself, anything under it, nor a parent such as `/run/secrets`.

#### `env`

Extra environment (map) passed to movers for this target — backend credentials, endpoints, etc. Values support `${ENV}` expansion.
Expand Down Expand Up @@ -276,7 +291,18 @@ be clean absolute paths without `:` (no `.`, `..`, `//` or trailing `/`); a
config using e.g. `/etc//sabon/pw` is rejected at startup. For remote backends, `credentialsFile`
does the same for S3/B2/… credentials: an AWS shared-credentials file mounted
read-only and exposed via `AWS_SHARED_CREDENTIALS_FILE`, keeping the keys out of
`env` too. Don't also put `AWS_*` in `env` — env wins over the file.
`env` too. Don't also put `AWS_*` in `env` — env wins over the file. For any
other file-based secret, use [`files`](#files); the same readability rule
applies.

`passwordFile`, `credentialsFile` and every `files[].source` are **host paths**,
like a local `path`: the Docker daemon resolves them when it starts the mover,
while sabon checks them from inside the orchestrator before each run. Mount each
file into the orchestrator at the **same path** it has on the host, otherwise the
check and the mover see different files. A file missing on the host fails the
run with `bind source path does not exist`. On Swarm the mover runs on the node
of the app it backs up, so the file must exist at that path on **every node**
that may run such an app.

### Placeholders

Expand Down
13 changes: 13 additions & 0 deletions examples/targets.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,16 @@ targets:
# resticArgs: ["--limit-upload=20480"] # global restic flag: throttle uplink ~20 MiB/s
# retention:
# daily: 7

# ── rest: a restic REST server behind a private CA ───────────────────
# `files` mounts host files read-only into every mover of this target.
# sabon sets no env for them: point restic (or the backend) at the
# in-mover `target` path yourself, here via a global restic flag.
#
# - name: rest
# repo: "rest:https://backup.example.internal:8000/{app}"
# passwordFile: /run/secrets/restic_password
# files:
# - source: /etc/sabon/internal-ca.pem # host path
# target: /secrets/ca.pem # path inside the mover
# resticArgs: ["--cacert=/secrets/ca.pem"]
42 changes: 42 additions & 0 deletions internal/backup/backup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,48 @@ func TestResticEnvCredentialsFile(t *testing.T) {
assertSecretMount(t, o, target, cf, awsCredentialsMount)
}

func TestResticEnvFiles(t *testing.T) {
dir := t.TempDir()
key := filepath.Join(dir, "key")
hosts := filepath.Join(dir, "known_hosts")
for _, p := range []string{key, hosts} {
if err := os.WriteFile(p, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
}
t.Setenv("R_PW_FILES", "pw")
o := &Orchestrator{cfg: &config.Config{CacheVolume: "sabon-cache"}}
target := api.Target{Name: "sftp", Repo: "rclone:box:{app}", PasswordEnv: "R_PW_FILES", Files: []api.TargetFile{
{Source: key, Target: "/secrets/key"},
{Source: hosts, Target: "/secrets/known_hosts"},
}}
if _, err := o.resticEnv(target, "immich"); err != nil {
t.Fatalf("resticEnv: %v", err)
}
assertSecretMount(t, o, target, key, "/secrets/key")
assertSecretMount(t, o, target, hosts, "/secrets/known_hosts")

target.Files = []api.TargetFile{{Source: filepath.Join(dir, "missing"), Target: "/secrets/key"}}
if _, err := o.resticEnv(target, "immich"); err == nil {
t.Fatal("expected error for a missing file")
}
target.Files = []api.TargetFile{{Source: dir, Target: "/secrets/key"}}
if _, err := o.resticEnv(target, "immich"); err == nil {
t.Fatal("expected error for a directory source")
}
}

func TestReservedMoverPathsMatchMounts(t *testing.T) {
want := map[string]bool{dataMount: true, repoMount: true, cacheMount: true, restoreMount: true, passwordMount: true, awsCredentialsMount: true}
got := map[string]bool{}
for _, p := range api.ReservedMoverPaths() {
got[p] = true
}
if fmt.Sprint(got) != fmt.Sprint(want) {
t.Errorf("api.ReservedMoverPaths() = %v, want mounts %v", got, want)
}
}

// fakeSnap is a snapshot.Snapshotter over an in-memory "on this fs" set.
type fakeSnap struct {
mode string
Expand Down
9 changes: 9 additions & 0 deletions internal/backup/mover.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ func (o *Orchestrator) cacheAndRepo(target api.Target, app string) ([]mount.Moun
if target.CredentialsFile != "" {
ms = append(ms, secretMount(target.CredentialsFile, awsCredentialsMount))
}
for _, f := range target.Files {
ms = append(ms, secretMount(f.Source, f.Target))
}
var binds []string
// Legacy bind on purpose: the daemon creates a missing repo directory.
if target.Backend() == api.BackendLocal {
Expand Down Expand Up @@ -146,6 +149,12 @@ func (o *Orchestrator) resticEnv(target api.Target, app string) ([]string, error
}
env = append(env, "AWS_SHARED_CREDENTIALS_FILE="+awsCredentialsMount)
}
for _, f := range target.Files {
fi, err := os.Stat(f.Source)
if err != nil || !fi.Mode().IsRegular() {
return nil, fmt.Errorf("target %q: file %q does not exist or is not a regular file", target.Name, f.Source)
}
}

for k, v := range target.Env {
env = append(env, k+"="+os.Expand(v, os.Getenv))
Expand Down
25 changes: 25 additions & 0 deletions schemas/targets.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,13 @@
"type": "string",
"description": "Host path to an AWS shared-credentials file; mounted into the mover and used via AWS_SHARED_CREDENTIALS_FILE (keeps S3 creds out of env)"
},
"files": {
"items": {
"$ref": "#/$defs/TargetFile"
},
"type": "array",
"description": "Host files mounted read-only into the mover (SSH key or known_hosts or rclone config); reference the target path from env"
},
"env": {
"additionalProperties": {
"type": "string"
Expand Down Expand Up @@ -122,6 +129,24 @@
"required": [
"name"
]
},
"TargetFile": {
"properties": {
"source": {
"type": "string",
"description": "Host path of the file"
},
"target": {
"type": "string",
"description": "Absolute path inside the mover"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"source",
"target"
]
}
}
}
Loading