From aa163c8b20ad367e0b91f2951eb9f05fede5e76a Mon Sep 17 00:00:00 2001 From: Martin Bertschler Date: Thu, 11 Jun 2026 05:41:31 +0200 Subject: [PATCH 1/4] config: add optional s3 storage_class and sftp host-key validation params s3 storage_class passes through to rclone's s3 storage_class config key (backend-specific values). sftp known_hosts_file and host_key_algorithms map to the rclone sftp options of the same name; pointing rclone at a known_hosts file is what enables server host-key validation, which sftp destinations otherwise skip. All three are optionalString passthroughs confined to their backend type by the existing unknown-field check. --- config/config_test.go | 97 ++++++++++++++++++++++++++++++++++++++++++ config/destinations.go | 14 +++++- 2 files changed, 109 insertions(+), 2 deletions(-) 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": { From 4d5fe9dbdbcb24d960b44876501903eb9f62a2a5 Mon Sep 17 00:00:00 2001 From: Martin Bertschler Date: Thu, 11 Jun 2026 05:41:37 +0200 Subject: [PATCH 2/4] sync: cover rendering of s3 storage_class and sftp host-key params End-to-end WriteRcloneConfig tests confirming the new optional params land in the written rclone.conf. --- sync/rclone_test.go | 55 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) 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 From 294f31ac179ce262fa261de3756248ffb9f37e0f Mon Sep 17 00:00:00 2001 From: Martin Bertschler Date: Thu, 11 Jun 2026 05:41:37 +0200 Subject: [PATCH 3/4] README: document s3 storage_class and sftp host-key validation params Note that sftp destinations skip host-key validation unless known_hosts_file is set, and recommend setting it. --- README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/README.md b/README.md index 5b7a670..231527c 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 backend supports (commonly `STANDARD` and various archive tiers such as `GLACIER` or `DEEP_ARCHIVE`); absent, the backend's default class is used. + + ```toml + [destinations.offsite] + type = "s3" + # ... + storage_class = "DEEP_ARCHIVE" # backend-specific; 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 From 336a1469ec1cb682335bad843b1572c97345c4ae Mon Sep 17 00:00:00 2001 From: Martin Bertschler Date: Thu, 11 Jun 2026 05:51:48 +0200 Subject: [PATCH 4/4] README: genericize the s3 storage_class example (no provider tier names) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 231527c..65440c0 100644 --- a/README.md +++ b/README.md @@ -72,13 +72,13 @@ Some optional params are specific to one backend type and rejected on the others 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 backend supports (commonly `STANDARD` and various archive tiers such as `GLACIER` or `DEEP_ARCHIVE`); absent, the backend's default class is used. +- **`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 = "DEEP_ARCHIVE" # backend-specific; archive tiers cost less to store, more to read + 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.