diff --git a/README.md b/README.md index 5b7a670..65440c0 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,30 @@ root = "/squirrel" Supported destination types: `local`, `sftp`, `s3`, `b2`, `gcs` (rclone-backed), and `kopia` (see [kopia destinations](#kopia-destinations)). Secrets accept either a literal string or an inline `{ env = "VAR_NAME" }` table that is resolved at load time. Unknown fields, missing required fields, and unset env vars are rejected immediately — squirrel will not invoke rclone with a misconfigured destination. +Some optional params are specific to one backend type and rejected on the others (as an unknown field): + +- **`sftp` host-key validation** — `known_hosts_file` points rclone at a known_hosts file so it validates the server's host key before transferring; `host_key_algorithms` is rclone's space-separated list pinning the accepted host-key algorithms. Both map to the rclone sftp options of the same name. **Without `known_hosts_file`, rclone does not validate the server's host key** and will connect to whatever host answers — set it (recommended) so a redirected or impersonated server is rejected. + + ```toml + [destinations.nas] + type = "sftp" + host = "nas.local" + user = "martin" + password = { env = "NAS_PASSWORD" } + root = "/volume1/squirrel" + known_hosts_file = "~/.ssh/known_hosts" # validate the server host key (recommended) + host_key_algorithms = "ssh-ed25519 ssh-rsa" # optional: pin accepted host-key algorithms + ``` + +- **`s3` storage class** — `storage_class` maps to rclone's s3 `storage_class` config key and accepts whatever value the chosen s3-compatible backend supports (typically a default tier plus one or more cheaper archive/cold tiers); absent, the backend's default class is used. Use the exact value string your provider documents. + + ```toml + [destinations.offsite] + type = "s3" + # ... + storage_class = "" # archive tiers cost less to store, more to read + ``` + Squirrel writes its own `rclone.conf` next to the config (`~/.squirrel/rclone.conf`, mode 0600) on every sync invocation. You do not run `rclone config` and you should not edit `rclone.conf` by hand. ### Encrypted destinations diff --git a/config/config_test.go b/config/config_test.go index 5e5b5c5..eec2e98 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -405,6 +405,103 @@ root = "/p" } } +// TestLoadDestinationS3StorageClass parses the optional s3 storage_class and +// confirms it renders verbatim into the s3 section. +func TestLoadDestinationS3StorageClass(t *testing.T) { + p := writeConfig(t, ` +[destinations.archive] +type = "s3" +provider = "AWS" +bucket = "squirrel" +root = "/p" +storage_class = "DEEP_ARCHIVE" +`) + cfg, err := Load(p) + if err != nil { + t.Fatalf("Load: %v", err) + } + d := cfg.Destinations["archive"] + if d.Params["storage_class"] != "DEEP_ARCHIVE" { + t.Fatalf("storage_class not resolved: %v", d.Params) + } + if !strings.Contains(d.RcloneSection(), "storage_class = DEEP_ARCHIVE") { + t.Fatalf("section missing storage_class:\n%s", d.RcloneSection()) + } +} + +// TestLoadRejectsStorageClassOnNonS3 confirms storage_class is confined to +// the s3 type by the unknown-field check — it has no meaning on an sftp +// destination. +func TestLoadRejectsStorageClassOnNonS3(t *testing.T) { + p := writeConfig(t, ` +[destinations.nas] +type = "sftp" +host = "h" +user = "u" +root = "/r" +storage_class = "GLACIER" +`) + _, err := Load(p) + if err == nil || !strings.Contains(err.Error(), `unknown field "storage_class"`) { + t.Fatalf("expected storage_class rejected on sftp, got %v", err) + } +} + +// TestLoadDestinationSFTPHostKeyValidation parses the optional sftp +// known_hosts_file and host_key_algorithms params and confirms both render +// verbatim into the sftp section. Pointing rclone at a known_hosts file is +// what turns on server host-key validation; absent, rclone accepts any host +// key the server presents. +func TestLoadDestinationSFTPHostKeyValidation(t *testing.T) { + p := writeConfig(t, ` +[destinations.nas] +type = "sftp" +host = "h" +user = "u" +root = "/r" +password = "p" +known_hosts_file = "~/.ssh/known_hosts" +host_key_algorithms = "ssh-ed25519 ssh-rsa" +`) + cfg, err := Load(p) + if err != nil { + t.Fatalf("Load: %v", err) + } + d := cfg.Destinations["nas"] + if d.Params["known_hosts_file"] != "~/.ssh/known_hosts" { + t.Fatalf("known_hosts_file not resolved: %v", d.Params) + } + if d.Params["host_key_algorithms"] != "ssh-ed25519 ssh-rsa" { + t.Fatalf("host_key_algorithms not resolved: %v", d.Params) + } + section := d.RcloneSection() + for _, want := range []string{ + "known_hosts_file = ~/.ssh/known_hosts", + "host_key_algorithms = ssh-ed25519 ssh-rsa", + } { + if !strings.Contains(section, want) { + t.Fatalf("section missing %q:\n%s", want, section) + } + } +} + +// TestLoadRejectsKnownHostsFileOnNonSFTP confirms the host-key params are +// confined to the sftp type by the unknown-field check. +func TestLoadRejectsKnownHostsFileOnNonSFTP(t *testing.T) { + p := writeConfig(t, ` +[destinations.s3] +type = "s3" +provider = "AWS" +bucket = "squirrel" +root = "/p" +known_hosts_file = "~/.ssh/known_hosts" +`) + _, err := Load(p) + if err == nil || !strings.Contains(err.Error(), `unknown field "known_hosts_file"`) { + t.Fatalf("expected known_hosts_file rejected on s3, got %v", err) + } +} + // TestLoadDestinationCrypt parses a crypt block with one env-resolved and // one literal password, the same secret forms destination credentials // accept. diff --git a/config/destinations.go b/config/destinations.go index 660f46c..7edec9c 100644 --- a/config/destinations.go +++ b/config/destinations.go @@ -43,15 +43,25 @@ var destSchemas = map[string]destSchema{ // an rclone backend param for the local case. }, "sftp": { + // known_hosts_file points rclone at a known_hosts file so it + // validates the server's host key before transferring; absent, rclone + // accepts whatever host key the server presents. host_key_algorithms + // pins the accepted host-key algorithms (rclone's space-separated + // list). Both map straight to the rclone sftp options of the same + // name. The unknown-field check confines them to this type. rcloneType: "sftp", requiredString: []string{"host", "user"}, - optionalString: []string{"port", "key_file"}, + optionalString: []string{"port", "key_file", "known_hosts_file", "host_key_algorithms"}, secretFields: []string{"password"}, }, "s3": { + // storage_class maps to rclone's s3 storage_class config key; its + // accepted values are whatever the backend supports (commonly + // STANDARD and various archive tiers). The unknown-field check + // confines it to this type. rcloneType: "s3", requiredString: []string{"provider", "bucket"}, - optionalString: []string{"region", "endpoint"}, + optionalString: []string{"region", "endpoint", "storage_class"}, secretFields: []string{"access_key_id", "secret_access_key"}, }, "b2": { diff --git a/sync/rclone_test.go b/sync/rclone_test.go index 02b7914..647c08a 100644 --- a/sync/rclone_test.go +++ b/sync/rclone_test.go @@ -159,6 +159,61 @@ password = "p" } } +// TestWriteRcloneConfigRendersSFTPHostKeyValidation confirms the optional +// sftp host-key params reach the written rclone.conf: known_hosts_file is +// what enables server host-key validation, and host_key_algorithms pins the +// accepted algorithms. Absent these, rclone does no host-key validation. +func TestWriteRcloneConfigRendersSFTPHostKeyValidation(t *testing.T) { + cfg := writeFakeConfig(t, ` +[destinations.nas] +type = "sftp" +host = "nas.local" +user = "martin" +root = "/data" +password = "p" +known_hosts_file = "~/.ssh/known_hosts" +host_key_algorithms = "ssh-ed25519 ssh-rsa" +`) + r := &Rclone{} + target := filepath.Join(t.TempDir(), "rclone.conf") + if _, err := r.WriteRcloneConfig(target, cfg.Destinations); err != nil { + t.Fatalf("WriteRcloneConfig: %v", err) + } + body, _ := os.ReadFile(target) + for _, want := range []string{ + "known_hosts_file = ~/.ssh/known_hosts", + "host_key_algorithms = ssh-ed25519 ssh-rsa", + } { + if !strings.Contains(string(body), want) { + t.Fatalf("rclone.conf missing %q:\n%s", want, body) + } + } +} + +// TestWriteRcloneConfigRendersS3StorageClass confirms the optional s3 +// storage_class reaches the written rclone.conf. +func TestWriteRcloneConfigRendersS3StorageClass(t *testing.T) { + cfg := writeFakeConfig(t, ` +[destinations.archive] +type = "s3" +provider = "AWS" +bucket = "squirrel" +root = "/p" +storage_class = "DEEP_ARCHIVE" +access_key_id = "AK" +secret_access_key = "sk" +`) + r := &Rclone{} + target := filepath.Join(t.TempDir(), "rclone.conf") + if _, err := r.WriteRcloneConfig(target, cfg.Destinations); err != nil { + t.Fatalf("WriteRcloneConfig: %v", err) + } + body, _ := os.ReadFile(target) + if !strings.Contains(string(body), "storage_class = DEEP_ARCHIVE") { + t.Fatalf("rclone.conf missing storage_class:\n%s", body) + } +} + // TestWriteRcloneConfigTightensExistingPermissions exercises the chmod // path. OpenFile's perm argument is only honored on create, so a file // that already exists with looser perms (e.g., 0644 from a previous