diff --git a/api/targets.go b/api/targets.go index 9633158..d246fc4 100644 --- a/api/targets.go +++ b/api/targets.go @@ -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"` @@ -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 != "" { @@ -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 } diff --git a/api/targets_test.go b/api/targets_test.go index e46adf9..dafd131 100644 --- a/api/targets_test.go +++ b/api/targets_test.go @@ -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) { diff --git a/docs/configuration.md b/docs/configuration.md index 019c33e..ba8c566 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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. @@ -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 diff --git a/examples/targets.yaml b/examples/targets.yaml index 1b17647..766a061 100644 --- a/examples/targets.yaml +++ b/examples/targets.yaml @@ -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"] diff --git a/internal/backup/backup_test.go b/internal/backup/backup_test.go index 7b93dbb..dca63a0 100644 --- a/internal/backup/backup_test.go +++ b/internal/backup/backup_test.go @@ -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 diff --git a/internal/backup/mover.go b/internal/backup/mover.go index 0080a87..f93f8c8 100644 --- a/internal/backup/mover.go +++ b/internal/backup/mover.go @@ -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 { @@ -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)) diff --git a/schemas/targets.json b/schemas/targets.json index 5b4cc4b..86d265f 100644 --- a/schemas/targets.json +++ b/schemas/targets.json @@ -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" @@ -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" + ] } } }