diff --git a/Cargo.lock b/Cargo.lock index 7acb800..8bd4b0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2278,6 +2278,8 @@ dependencies = [ "attic", "aws-config", "aws-sdk-s3", + "aws-smithy-runtime-api", + "aws-smithy-types", "base64 0.22.1", "bytes", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 832de8d..ec232bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,8 @@ serde_with = "3.14.0" ed25519-compact = "2.1.1" rusqlite = { version = "0.31.0", features = ["bundled"] } http = "0.2" +aws-smithy-runtime-api = "1" +aws-smithy-types = "1" sha2 = "0.10.8" hex = "0.4.3" redb = "3.0.1" diff --git a/README.md b/README.md index ba68df7..9adc09c 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,27 @@ In your `configuration.nix` (or a related file), you can now enable and configur # It's highly recommended to use sops-nix or agenix for secrets accessKeyFile = "/path/to/your/s3-access-key"; secretKeyFile = "/path/to/your/s3-secret-key"; + + # Optional: Extra HTTP headers for every S3 request (e.g., Cloudflare Access). + # Three methods, all of which are combined (not overridden): + # + # 1. Inline headers (non-secret, baked into the world-readable Nix store): + extraHeaders = { + "CF-Access-Client-Id" = "xxx"; + "CF-Access-Client-Secret" = "yyy"; + }; + + # 2. File-based headers (secret, read at runtime โ€” works with sops-nix/agenix): + extraHeadersFile = { + "CF-Access-Client-Id" = "/run/secrets/cf-access-id"; + "CF-Access-Client-Secret" = "/run/secrets/cf-access-secret"; + }; }; + # 3. Or set LOFT_EXTRA_HEADER_* env vars directly on the systemd service. + # Underscores map to hyphens in header names. + # Example: LOFT_EXTRA_HEADER_CF_ACCESS_CLIENT_ID โ†’ "CF-Access-Client-Id" + # --- Loft Service Configuration --- debug = false; # Enable debug logging localCachePath = "/var/lib/loft/cache.db"; @@ -120,6 +139,8 @@ When using the NixOS module, please be aware of the following: * **`localCachePath` Location:** Due to the security sandboxing of the systemd service, the `localCachePath` must be located within the `/var/lib/loft` directory. The service does not have permission to write to other locations on the filesystem. The default path is `/var/lib/loft/cache.db`, which is the recommended setting. +* **Extra Headers:** The `extraHeaders` and `extraHeadersFile` options are combined. You can use both simultaneously โ€” inline headers for non-secrets and file-based headers for secrets. All headers are merged into every S3 request. + ## Configuration Loft is configured via a `loft.toml` file. The NixOS module generates this file for you. For other systems, you may need to create it manually. @@ -132,6 +153,12 @@ bucket = "nix-cache" region = "us-east-1" endpoint = "http://172.16.1.50:31292" +# Optional: Extra HTTP headers for every S3 request (e.g., Cloudflare Access) +# Inline (non-secret): +# [s3.extra_headers] +# "CF-Access-Client-Id" = "xxx" +# "CF-Access-Client-Secret" = "yyy" + [loft] upload_threads = 12 scan_on_startup = true @@ -149,6 +176,22 @@ prune_retention_days = 30 # prune_schedule = "24h" ``` +## Extra HTTP Headers + +Loft supports adding custom HTTP headers to every S3 request, which is useful for authentication proxies like Cloudflare Access. There are three ways to provide them, all of which are **merged together** (not overridden): + +| Method | Scope | Use case | +|--------|-------|----------| +| `[s3.extra_headers]` in TOML | Config file | Non-secret headers baked into config | +| `LOFT_EXTRA_HEADER_*` env vars | Process environment | Secrets (follows `AWS_ACCESS_KEY_ID` pattern) | +| `extraHeadersFile` in NixOS | NixOS module | Secrets from sops-nix/agenix files | + +**Env var naming convention**: `LOFT_EXTRA_HEADER_` + header name with hyphens replaced by underscores. For example, `LOFT_EXTRA_HEADER_CF_ACCESS_CLIENT_ID` sets the `CF-Access-Client-Id` header. + +**NixOS wrapper**: When using `extraHeadersFile`, the module's systemd wrapper reads each file at runtime and exports the value as the corresponding `LOFT_EXTRA_HEADER_*` env var before launching loft. + +**Implementation detail**: Headers are injected via an SDK interceptor at the `modify_before_transmit` phase โ€” after SigV4 signing. This is intentional: auth proxy headers like `CF-Access-*` are consumed and stripped by Cloudflare before the request reaches S3, so they must not be part of the AWS signature. + ## Command-line arguments You can also use command-line arguments to override settings or perform one-off actions. diff --git a/flake.nix b/flake.nix index 41cadbe..c3a17ba 100644 --- a/flake.nix +++ b/flake.nix @@ -286,6 +286,16 @@ EOF echo "" echo "๐Ÿงช Cache testing script available! Run: cache-test" echo " This will build loft with nom, then clean up all artifacts." + echo "" + echo "๐Ÿงช Run all checks (integration, clippy, unit-tests):" + echo " nix flake check" + echo "" + echo " Or individually:" + echo " nix build .#checks.${system}.integration" + echo " nix build .#checks.${system}.clippy" + echo " nix build .#checks.${system}.unit-tests" + echo "" + echo " Use --rebuild to re-run a cached result (e.g. nix build .#checks.${system}.integration --rebuild)" ''; }; }; diff --git a/nixos/module.nix b/nixos/module.nix index 1d90525..aeeaee5 100644 --- a/nixos/module.nix +++ b/nixos/module.nix @@ -15,6 +15,7 @@ let baseConfig = { s3 = { inherit (cfg.s3) bucket region endpoint; + extra_headers = cfg.s3.extraHeaders; }; loft = { upload_threads = cfg.uploadThreads; @@ -46,6 +47,12 @@ let # Generate the final loft.toml content, stripping nulls loftToml = tomlFormat.generate "loft.toml" (removeNulls finalConfig); + # Build shell lines that export extra headers from files at runtime + # (built outside the script to avoid nested indented-string conflicts) + extraHeaderExports = lib.concatStringsSep "\n" (lib.mapAttrsToList (name: path: + "export LOFT_EXTRA_HEADER_${lib.toUpper (lib.replaceStrings ["-"] ["_"] name)}=$(cat ${path})" + ) cfg.s3.extraHeadersFile); + in { ###### OPTIONS ###### @@ -71,6 +78,18 @@ in endpoint = mkOption { type = types.str; description = "S3 endpoint URL for the cache."; }; accessKeyFile = mkOption { type = types.path; description = "Path to a file containing the S3 access key."; }; secretKeyFile = mkOption { type = types.path; description = "Path to a file containing the S3 secret key."; }; + extraHeaders = mkOption { + type = types.attrsOf types.str; + default = { }; + description = "Extra HTTP headers to include on every S3 request. Useful for authentication proxies (e.g., Cloudflare Access)."; + example = { "CF-Access-Client-Id" = "xxx"; "CF-Access-Client-Secret" = "yyy"; }; + }; + extraHeadersFile = mkOption { + type = types.attrsOf types.path; + default = { }; + description = "Extra HTTP headers read from files at runtime. The attribute keys are header names, values are paths to files containing the header value. Useful with sops-nix or agenix for secrets."; + example = { "CF-Access-Client-Id" = "/run/secrets/cf-access-id"; }; + }; }; localCachePath = mkOption { @@ -171,6 +190,7 @@ in set -eu export AWS_ACCESS_KEY_ID=$(cat ${cfg.s3.accessKeyFile}) export AWS_SECRET_ACCESS_KEY=$(cat ${cfg.s3.secretKeyFile}) + ${extraHeaderExports} export PATH=${pkgs.nix}/bin:$PATH exec ${loft-pkg}/bin/loft --config ${loftToml} ${optionalString cfg.debug "--debug"} ''; diff --git a/nixos/tests/integration.nix b/nixos/tests/integration.nix index d766712..c6629a3 100644 --- a/nixos/tests/integration.nix +++ b/nixos/tests/integration.nix @@ -10,6 +10,22 @@ virtualisation.memorySize = 2048; virtualisation.diskSize = 4096; + services.nginx = { + enable = true; + virtualHosts."auth-proxy" = { + listen = [ { port = 3902; addr = "0.0.0.0"; } ]; + locations."/" = { + proxyPass = "http://localhost:3900"; + extraConfig = '' + proxy_set_header Host $host:$server_port; + if ($http_x_loft_auth != "test-token") { + return 403; + } + ''; + }; + }; + }; + services.garage = { enable = true; package = pkgs.garage; @@ -59,7 +75,7 @@ sandbox = false; }; - networking.firewall.allowedTCPPorts = [ 3900 3901 ]; + networking.firewall.allowedTCPPorts = [ 3900 3901 3902 ]; }; testScript = '' @@ -98,7 +114,9 @@ machine.log("Verified: " + path + " is fetchable from S3") machine.start() + machine.wait_for_unit("nginx.service", timeout=30) machine.wait_for_unit("garage.service", timeout=30) + machine.wait_for_open_port(3902, timeout=10) machine.wait_for_open_port(3900, timeout=10) with subtest("Initialize Garage"): @@ -244,6 +262,75 @@ for p in bulk_paths: verify_cache(p, s3_url) - machine.log("All advanced integration tests passed!") + with subtest("Extra Headers: inline config [s3.extra_headers]"): + reset_state() + cfg = ( + "[s3]\nbucket = \"loft-test-bucket\"\nregion = \"us-east-1\"\nendpoint = \"http://localhost:3902\"\n" + "access_key = \"" + access_key + "\"\nsecret_key = \"" + secret_key + "\"\n" + "[s3.extra_headers]\n\"X-Loft-Auth\" = \"test-token\"\n" + "[loft]\nupload_threads = 1\nscan_on_startup = true\n" + "local_cache_path = \"/var/lib/loft/cache-hdr-inline.db\"\n" + ) + p = machine.succeed( + "nix build --no-link --print-out-paths --expr 'derivation { name = \"hdr-inline\"; builder = \"/bin/sh\"; args = [ \"-c\" \"echo hi > $out\" ]; system = \"${pkgs.system}\"; PATH = \"/run/current-system/sw/bin\"; }' --impure" + ).strip() + secure_copy(cfg, "/tmp/loft-hdr-inline.toml") + machine.succeed(with_s3("loft --config /tmp/loft-hdr-inline.toml --force-scan")) + verify_cache(p, s3_url) + + with subtest("Extra Headers: LOFT_EXTRA_HEADER_* env var"): + reset_state() + cfg = ( + "[s3]\nbucket = \"loft-test-bucket\"\nregion = \"us-east-1\"\nendpoint = \"http://localhost:3902\"\n" + "access_key = \"" + access_key + "\"\nsecret_key = \"" + secret_key + "\"\n" + "[loft]\nupload_threads = 1\nscan_on_startup = true\n" + "local_cache_path = \"/var/lib/loft/cache-hdr-env.db\"\n" + ) + p = machine.succeed( + "nix build --no-link --print-out-paths --expr 'derivation { name = \"hdr-env\"; builder = \"/bin/sh\"; args = [ \"-c\" \"echo he > $out\" ]; system = \"${pkgs.system}\"; PATH = \"/run/current-system/sw/bin\"; }' --impure" + ).strip() + secure_copy(cfg, "/tmp/loft-hdr-env.toml") + machine.succeed( + with_s3("LOFT_EXTRA_HEADER_X_LOFT_AUTH=test-token loft --config /tmp/loft-hdr-env.toml --force-scan") + ) + verify_cache(p, s3_url) + + with subtest("Extra Headers: file-based via LOFT_EXTRA_HEADER_*"): + reset_state() + secure_copy("test-token\n", "/run/secrets/loft-auth-token") + cfg = ( + "[s3]\nbucket = \"loft-test-bucket\"\nregion = \"us-east-1\"\nendpoint = \"http://localhost:3902\"\n" + "access_key = \"" + access_key + "\"\nsecret_key = \"" + secret_key + "\"\n" + "[loft]\nupload_threads = 1\nscan_on_startup = true\n" + "local_cache_path = \"/var/lib/loft/cache-hdr-file.db\"\n" + ) + p = machine.succeed( + "nix build --no-link --print-out-paths --expr 'derivation { name = \"hdr-file\"; builder = \"/bin/sh\"; args = [ \"-c\" \"echo hf > $out\" ]; system = \"${pkgs.system}\"; PATH = \"/run/current-system/sw/bin\"; }' --impure" + ).strip() + secure_copy(cfg, "/tmp/loft-hdr-file.toml") + machine.succeed( + with_s3("LOFT_EXTRA_HEADER_X_LOFT_AUTH=$(cat /run/secrets/loft-auth-token) loft --config /tmp/loft-hdr-file.toml --force-scan") + ) + verify_cache(p, s3_url) + + with subtest("Extra Headers: missing header โ€” paths not uploaded"): + reset_state() + cfg = ( + "[s3]\nbucket = \"loft-test-bucket\"\nregion = \"us-east-1\"\nendpoint = \"http://localhost:3902\"\n" + "access_key = \"" + access_key + "\"\nsecret_key = \"" + secret_key + "\"\n" + "[loft]\nupload_threads = 1\nscan_on_startup = true\n" + "local_cache_path = \"/var/lib/loft/cache-hdr-noauth.db\"\n" + ) + p = machine.succeed( + "nix build --no-link --print-out-paths --expr 'derivation { name = \"hdr-noauth\"; builder = \"/bin/sh\"; args = [ \"-c\" \"echo hn > $out\" ]; system = \"${pkgs.system}\"; PATH = \"/run/current-system/sw/bin\"; }' --impure" + ).strip() + secure_copy(cfg, "/tmp/loft-hdr-noauth.toml") + _status, _output = machine.execute(with_s3("loft --config /tmp/loft-hdr-noauth.toml --force-scan 2>&1")) + hash_part = p.split("/")[-1].split("-")[0] + import time + time.sleep(5) + machine.fail(with_s3("aws --endpoint-url http://localhost:3900 s3 ls s3://loft-test-bucket/" + hash_part + ".narinfo")) + + machine.log("All integration tests passed!") ''; } diff --git a/src/config.rs b/src/config.rs index 81f5247..91bc7ff 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,6 +2,7 @@ use anyhow::{Context, Result}; use serde::Deserialize; +use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; @@ -102,6 +103,10 @@ pub struct S3Config { pub endpoint: String, pub access_key: Option, pub secret_key: Option, + /// Optional extra HTTP headers to include on every S3 request. + /// Useful for authentication proxies (e.g., Cloudflare Access). + #[serde(default)] + pub extra_headers: HashMap, } /// Sets the default number of upload threads if not specified. diff --git a/src/s3_uploader.rs b/src/s3_uploader.rs index 3bd0385..ef2d956 100644 --- a/src/s3_uploader.rs +++ b/src/s3_uploader.rs @@ -4,8 +4,14 @@ use aws_config::BehaviorVersion; use aws_sdk_s3::config::{Credentials, Region}; use aws_sdk_s3::primitives::ByteStream; use aws_sdk_s3::Client; +use aws_smithy_runtime_api::box_error::BoxError; +use aws_smithy_runtime_api::client::interceptors::context::BeforeTransmitInterceptorContextMut; +use aws_smithy_runtime_api::client::interceptors::Intercept; +use aws_smithy_runtime_api::client::runtime_components::RuntimeComponents; +use aws_smithy_types::config_bag::ConfigBag; use futures::StreamExt; use http::StatusCode; +use std::collections::HashMap; use std::path::Path; use std::sync::Arc; use tokio::sync::Semaphore; @@ -60,10 +66,25 @@ impl S3Uploader { let sdk_config = config_loader.load().await; - let s3_config = aws_sdk_s3::config::Builder::from(&sdk_config) - .force_path_style(true) - .build(); + let mut s3_config_builder = aws_sdk_s3::config::Builder::from(&sdk_config) + .force_path_style(true); + let mut extra_headers = config.extra_headers.clone(); + + for (env_name, env_value) in std::env::vars() { + if let Some(header_name) = env_name.strip_prefix("LOFT_EXTRA_HEADER_") { + let header_name = header_name.replace('_', "-"); + extra_headers.insert(header_name, env_value); + } + } + + if !extra_headers.is_empty() { + s3_config_builder = s3_config_builder.interceptor(CustomHeadersInterceptor { + headers: extra_headers, + }); + } + + let s3_config = s3_config_builder.build(); let client = Client::from_conf(s3_config); Ok(S3Uploader { @@ -629,6 +650,33 @@ impl RemoteCacheStorage for S3Uploader { } } +#[derive(Debug)] +struct CustomHeadersInterceptor { + headers: HashMap, +} + +impl Intercept for CustomHeadersInterceptor { + fn name(&self) -> &'static str { + "custom_headers_interceptor" + } + + fn modify_before_transmit( + &self, + context: &mut BeforeTransmitInterceptorContextMut<'_>, + _runtime_components: &RuntimeComponents, + _cfg: &mut ConfigBag, + ) -> Result<(), BoxError> { + let headers = context.request_mut().headers_mut(); + for (name, value) in &self.headers { + headers.insert( + http::HeaderName::from_bytes(name.as_bytes())?, + http::HeaderValue::from_str(value)?, + ); + } + Ok(()) + } +} + // Helper trait to convert aws_sdk_s3::types::DateTime to chrono::DateTime pub trait AwsDateTimeExt { fn to_chrono_utc(&self) -> DateTime;