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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<provider archive tier>" # 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
Expand Down
97 changes: 97 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +450 to +454
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)
}
Comment on lines +471 to +473
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",
} {
Comment on lines +478 to +481
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.
Expand Down
14 changes: 12 additions & 2 deletions config/destinations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +46 to +51
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": {
Expand Down
55 changes: 55 additions & 0 deletions sync/rclone_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +162 to +165
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",
} {
Comment on lines +183 to +186
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
Expand Down
Loading