From fe0465ddef9d1121a37892cb7b792246256ecd4c Mon Sep 17 00:00:00 2001 From: Kyle Petryszak <6314611+ProjectInitiative@users.noreply.github.com> Date: Sat, 9 May 2026 13:49:14 +0000 Subject: [PATCH 1/8] feat: support custom HTTP headers for S3 requests (Cloudflare Access, etc.) Add three ways to set extra HTTP headers on every S3 request, all of which are merged together: - [s3.extra_headers] in TOML config for non-secret inline headers - LOFT_EXTRA_HEADER_* env vars (underscores map to hyphens) - extraHeadersFile NixOS option for file-based secrets (sops-nix/agenix) Headers are injected via an SDK interceptor at the modify_before_transmit phase (after SigV4 signing), so auth proxy headers are consumed by the proxy before reaching the S3 endpoint without causing signature mismatches. --- Cargo.lock | 2 ++ Cargo.toml | 2 ++ README.md | 43 ++++++++++++++++++++++++++++++++++++ nixos/module.nix | 20 +++++++++++++++++ src/config.rs | 5 +++++ src/s3_uploader.rs | 54 +++++++++++++++++++++++++++++++++++++++++++--- 6 files changed, 123 insertions(+), 3 deletions(-) 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/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/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; From bee39aa9b7952c227409b79383a22bacde285ffb Mon Sep 17 00:00:00 2001 From: Kyle Petryszak <6314611+ProjectInitiative@users.noreply.github.com> Date: Sat, 9 May 2026 20:25:21 +0000 Subject: [PATCH 2/8] test: add integration test for custom S3 HTTP headers Dedicated NixOS VM test that sets up nginx as an auth proxy in front of Garage requiring a X-Loft-Auth header on port 3902. Tests all three header mechanisms: - inline [s3.extra_headers] config - LOFT_EXTRA_HEADER_* env vars - file-based (simulating NixOS extraHeadersFile) - negative test: no headers, nginx returns 403, paths not uploaded --- flake.nix | 1 + nixos/tests/extra-headers.nix | 195 ++++++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 nixos/tests/extra-headers.nix diff --git a/flake.nix b/flake.nix index 41cadbe..06707dc 100644 --- a/flake.nix +++ b/flake.nix @@ -174,6 +174,7 @@ }; checks = { integration = pkgsForTest.nixosTest (import ./nixos/tests/integration.nix); + extra-headers = pkgsForTest.nixosTest (import ./nixos/tests/extra-headers.nix); clippy = loftClippy; unit-tests = loftNextest; }; diff --git a/nixos/tests/extra-headers.nix b/nixos/tests/extra-headers.nix new file mode 100644 index 0000000..b07ea1c --- /dev/null +++ b/nixos/tests/extra-headers.nix @@ -0,0 +1,195 @@ +{ pkgs, ... }: + +{ + name = "loft-extra-headers-test"; + + nodes.machine = { config, pkgs, lib, ... }: { + imports = [ ../module.nix ]; + + virtualisation.writableStore = true; + 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 = '' + if ($http_x_loft_auth != "test-token") { + return 403; + } + ''; + }; + }; + }; + + services.garage = { + enable = true; + package = pkgs.garage; + settings = { + metadata_dir = "/var/lib/garage/meta"; + data_dir = "/var/lib/garage/data"; + replication_factor = 1; + rpc_bind_addr = "[::]:3901"; + rpc_secret = "0000000000000000000000000000000000000000000000000000000000000000"; + s3_api = { + s3_region = "us-east-1"; + api_bind_addr = "[::]:3900"; + root_domain = ".s3.garage.localhost"; + }; + }; + }; + + environment.systemPackages = with pkgs; [ + awscli2 + jq + garage + loft + ]; + + nix.settings = { + experimental-features = [ "nix-command" "flakes" ]; + trusted-users = [ "root" ]; + sandbox = false; + }; + + networking.firewall.allowedTCPPorts = [ 3900 3901 3902 ]; + }; + + testScript = '' + import re + import tempfile + import os + + def with_s3(cmd): + return f"AWS_ACCESS_KEY_ID=$(cat /etc/loft-s3-access-key) AWS_SECRET_ACCESS_KEY=$(cat /etc/loft-s3-secret-key) AWS_DEFAULT_REGION=us-east-1 {cmd}" + + def secure_copy(content, target): + with tempfile.NamedTemporaryFile(mode='w', delete=False) as tf: + tf.write(content) + temp_name = tf.name + try: + machine.copy_from_host(temp_name, target) + finally: + os.remove(temp_name) + + def reset_s3(): + machine.log("Clearing S3 bucket...") + machine.succeed(with_s3("aws --endpoint-url http://localhost:3900 s3 rm s3://loft-test-bucket --recursive || true")) + machine.log("S3 bucket cleared.") + + 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"): + status = machine.succeed("garage status") + m = re.search(r"([0-9a-f]{16})", status) + if not m: raise Exception("No node ID") + node_id = m.group(1) + machine.succeed("garage layout assign " + node_id + " -z 1 -c 100M") + machine.succeed("garage layout apply --version 1") + key_info = machine.succeed("garage key create test-key") + ak_m = re.search(r"Key ID: (\S+)", key_info) + sk_m = re.search(r"Secret key: (\S+)", key_info) + if not ak_m or not sk_m: raise Exception("No keys") + access_key = ak_m.group(1) + secret_key = sk_m.group(1) + + secure_copy(access_key, "/etc/loft-s3-access-key") + secure_copy(secret_key, "/etc/loft-s3-secret-key") + + machine.succeed("garage bucket create loft-test-bucket") + machine.succeed("garage bucket allow loft-test-bucket --key test-key --read --write") + + s3_url = f"s3://loft-test-bucket?scheme=http&endpoint=localhost:3900®ion=us-east-1&access_key_id={access_key}&secret_access_key={secret_key}" + + def build_path(name): + return machine.succeed( + "nix build --no-link --print-out-paths " + "--expr 'derivation { name = \"" + name + "\"; builder = \"/bin/sh\"; " + "args = [ \"-c\" \"echo " + name + " > $out\" ]; " + "system = \"${pkgs.system}\"; PATH = \"/run/current-system/sw/bin\"; }' --impure" + ).strip() + + def verify_cached(path): + hash_part = path.split("/")[-1].split("-")[0] + narinfo = hash_part + ".narinfo" + machine.wait_until_succeeds( + with_s3("aws --endpoint-url http://localhost:3900 s3 ls s3://loft-test-bucket/" + narinfo), + timeout=60 + ) + machine.succeed("nix-store --delete --ignore-liveness " + path) + machine.succeed(with_s3( + "nix build " + path + " --substituters '" + s3_url + "' --option require-sigs false --max-jobs 0 --impure" + )) + + with subtest("Extra Headers: inline config [s3.extra_headers]"): + reset_s3() + 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-inline.db\"\n" + ) + p = build_path("hdr-inline") + secure_copy(cfg, "/tmp/loft-hdr-inline.toml") + machine.succeed(with_s3("loft --config /tmp/loft-hdr-inline.toml --force-scan")) + verify_cached(p) + + with subtest("Extra Headers: LOFT_EXTRA_HEADER_* env var"): + reset_s3() + 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-env.db\"\n" + ) + p = build_path("hdr-env") + 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_cached(p) + + with subtest("Extra Headers: NixOS extraHeadersFile (read from file)"): + reset_s3() + + 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-file.db\"\n" + ) + p = build_path("hdr-file") + 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_cached(p) + + with subtest("Extra Headers: missing header — paths not uploaded"): + reset_s3() + 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-noauth.db\"\n" + ) + p = build_path("hdr-noauth") + secure_copy(cfg, "/tmp/loft-hdr-noauth.toml") + machine.succeed(with_s3("loft --config /tmp/loft-hdr-noauth.toml --force-scan 2>&1; true")) + 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 extra headers integration tests passed!") + ''; +} From e2d399b812541d021c31193612071cb00b6eafe1 Mon Sep 17 00:00:00 2001 From: Kyle Petryszak <6314611+ProjectInitiative@users.noreply.github.com> Date: Sat, 9 May 2026 20:43:25 +0000 Subject: [PATCH 3/8] fix: preserve Host header through nginx proxy in extra-headers test The nginx auth proxy was changing the Host header when forwarding to Garage, breaking the AWS SigV4 signature validation. Added proxy_set_header Host $host:$server_port to preserve the original endpoint host:port so signatures match. --- nixos/tests/extra-headers.nix | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nixos/tests/extra-headers.nix b/nixos/tests/extra-headers.nix index b07ea1c..40c53b3 100644 --- a/nixos/tests/extra-headers.nix +++ b/nixos/tests/extra-headers.nix @@ -17,6 +17,7 @@ locations."/" = { proxyPass = "http://localhost:3900"; extraConfig = '' + proxy_set_header Host $host:$server_port; if ($http_x_loft_auth != "test-token") { return 403; } @@ -184,7 +185,7 @@ ) p = build_path("hdr-noauth") secure_copy(cfg, "/tmp/loft-hdr-noauth.toml") - machine.succeed(with_s3("loft --config /tmp/loft-hdr-noauth.toml --force-scan 2>&1; true")) + _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) From 45b2540f3721e00dd4d3633c131170c3e30f1905 Mon Sep 17 00:00:00 2001 From: Kyle Petryszak <6314611+ProjectInitiative@users.noreply.github.com> Date: Sat, 9 May 2026 23:52:03 +0000 Subject: [PATCH 4/8] chore: add integration test commands to devShell shell hook --- flake.nix | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/flake.nix b/flake.nix index 06707dc..8ea5f68 100644 --- a/flake.nix +++ b/flake.nix @@ -287,6 +287,12 @@ EOF echo "" echo "🧪 Cache testing script available! Run: cache-test" echo " This will build loft with nom, then clean up all artifacts." + echo "" + echo "🧪 Integration tests:" + echo " nix build .#checks.${system}.integration" + echo " nix build .#checks.${system}.extra-headers" + echo " nix build .#checks.${system}.clippy" + echo " nix build .#checks.${system}.unit-tests" ''; }; }; From a5a42946893178714c5ed0aaa388c86b94f8f1e5 Mon Sep 17 00:00:00 2001 From: Kyle Petryszak <6314611+ProjectInitiative@users.noreply.github.com> Date: Sat, 9 May 2026 23:53:05 +0000 Subject: [PATCH 5/8] chore: add nix flake check to shell hook for running all checks --- flake.nix | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 8ea5f68..6bb906c 100644 --- a/flake.nix +++ b/flake.nix @@ -288,7 +288,10 @@ EOF echo "🧪 Cache testing script available! Run: cache-test" echo " This will build loft with nom, then clean up all artifacts." echo "" - echo "🧪 Integration tests:" + echo "🧪 Run all checks (integration, extra-headers, clippy, unit-tests):" + echo " nix flake check" + echo "" + echo " Or individually:" echo " nix build .#checks.${system}.integration" echo " nix build .#checks.${system}.extra-headers" echo " nix build .#checks.${system}.clippy" From 5dd3cdf8cced73a240560bb854e848df6a993a8d Mon Sep 17 00:00:00 2001 From: Kyle Petryszak <6314611+ProjectInitiative@users.noreply.github.com> Date: Sun, 10 May 2026 00:05:02 +0000 Subject: [PATCH 6/8] refactor: merge extra-headers into integration.nix, share Garage setup --- flake.nix | 4 +- nixos/tests/extra-headers.nix | 196 ---------------------------------- nixos/tests/integration.nix | 91 +++++++++++++++- 3 files changed, 90 insertions(+), 201 deletions(-) delete mode 100644 nixos/tests/extra-headers.nix diff --git a/flake.nix b/flake.nix index 6bb906c..f3a86c2 100644 --- a/flake.nix +++ b/flake.nix @@ -174,7 +174,6 @@ }; checks = { integration = pkgsForTest.nixosTest (import ./nixos/tests/integration.nix); - extra-headers = pkgsForTest.nixosTest (import ./nixos/tests/extra-headers.nix); clippy = loftClippy; unit-tests = loftNextest; }; @@ -288,12 +287,11 @@ EOF 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, extra-headers, clippy, unit-tests):" + 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}.extra-headers" echo " nix build .#checks.${system}.clippy" echo " nix build .#checks.${system}.unit-tests" ''; diff --git a/nixos/tests/extra-headers.nix b/nixos/tests/extra-headers.nix deleted file mode 100644 index 40c53b3..0000000 --- a/nixos/tests/extra-headers.nix +++ /dev/null @@ -1,196 +0,0 @@ -{ pkgs, ... }: - -{ - name = "loft-extra-headers-test"; - - nodes.machine = { config, pkgs, lib, ... }: { - imports = [ ../module.nix ]; - - virtualisation.writableStore = true; - 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; - settings = { - metadata_dir = "/var/lib/garage/meta"; - data_dir = "/var/lib/garage/data"; - replication_factor = 1; - rpc_bind_addr = "[::]:3901"; - rpc_secret = "0000000000000000000000000000000000000000000000000000000000000000"; - s3_api = { - s3_region = "us-east-1"; - api_bind_addr = "[::]:3900"; - root_domain = ".s3.garage.localhost"; - }; - }; - }; - - environment.systemPackages = with pkgs; [ - awscli2 - jq - garage - loft - ]; - - nix.settings = { - experimental-features = [ "nix-command" "flakes" ]; - trusted-users = [ "root" ]; - sandbox = false; - }; - - networking.firewall.allowedTCPPorts = [ 3900 3901 3902 ]; - }; - - testScript = '' - import re - import tempfile - import os - - def with_s3(cmd): - return f"AWS_ACCESS_KEY_ID=$(cat /etc/loft-s3-access-key) AWS_SECRET_ACCESS_KEY=$(cat /etc/loft-s3-secret-key) AWS_DEFAULT_REGION=us-east-1 {cmd}" - - def secure_copy(content, target): - with tempfile.NamedTemporaryFile(mode='w', delete=False) as tf: - tf.write(content) - temp_name = tf.name - try: - machine.copy_from_host(temp_name, target) - finally: - os.remove(temp_name) - - def reset_s3(): - machine.log("Clearing S3 bucket...") - machine.succeed(with_s3("aws --endpoint-url http://localhost:3900 s3 rm s3://loft-test-bucket --recursive || true")) - machine.log("S3 bucket cleared.") - - 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"): - status = machine.succeed("garage status") - m = re.search(r"([0-9a-f]{16})", status) - if not m: raise Exception("No node ID") - node_id = m.group(1) - machine.succeed("garage layout assign " + node_id + " -z 1 -c 100M") - machine.succeed("garage layout apply --version 1") - key_info = machine.succeed("garage key create test-key") - ak_m = re.search(r"Key ID: (\S+)", key_info) - sk_m = re.search(r"Secret key: (\S+)", key_info) - if not ak_m or not sk_m: raise Exception("No keys") - access_key = ak_m.group(1) - secret_key = sk_m.group(1) - - secure_copy(access_key, "/etc/loft-s3-access-key") - secure_copy(secret_key, "/etc/loft-s3-secret-key") - - machine.succeed("garage bucket create loft-test-bucket") - machine.succeed("garage bucket allow loft-test-bucket --key test-key --read --write") - - s3_url = f"s3://loft-test-bucket?scheme=http&endpoint=localhost:3900®ion=us-east-1&access_key_id={access_key}&secret_access_key={secret_key}" - - def build_path(name): - return machine.succeed( - "nix build --no-link --print-out-paths " - "--expr 'derivation { name = \"" + name + "\"; builder = \"/bin/sh\"; " - "args = [ \"-c\" \"echo " + name + " > $out\" ]; " - "system = \"${pkgs.system}\"; PATH = \"/run/current-system/sw/bin\"; }' --impure" - ).strip() - - def verify_cached(path): - hash_part = path.split("/")[-1].split("-")[0] - narinfo = hash_part + ".narinfo" - machine.wait_until_succeeds( - with_s3("aws --endpoint-url http://localhost:3900 s3 ls s3://loft-test-bucket/" + narinfo), - timeout=60 - ) - machine.succeed("nix-store --delete --ignore-liveness " + path) - machine.succeed(with_s3( - "nix build " + path + " --substituters '" + s3_url + "' --option require-sigs false --max-jobs 0 --impure" - )) - - with subtest("Extra Headers: inline config [s3.extra_headers]"): - reset_s3() - 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-inline.db\"\n" - ) - p = build_path("hdr-inline") - secure_copy(cfg, "/tmp/loft-hdr-inline.toml") - machine.succeed(with_s3("loft --config /tmp/loft-hdr-inline.toml --force-scan")) - verify_cached(p) - - with subtest("Extra Headers: LOFT_EXTRA_HEADER_* env var"): - reset_s3() - 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-env.db\"\n" - ) - p = build_path("hdr-env") - 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_cached(p) - - with subtest("Extra Headers: NixOS extraHeadersFile (read from file)"): - reset_s3() - - 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-file.db\"\n" - ) - p = build_path("hdr-file") - 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_cached(p) - - with subtest("Extra Headers: missing header — paths not uploaded"): - reset_s3() - 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-noauth.db\"\n" - ) - p = build_path("hdr-noauth") - 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 extra headers integration tests passed!") - ''; -} 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!") ''; } From 5a6e868a9e4d51508fbe6e30f235364392b926b2 Mon Sep 17 00:00:00 2001 From: Kyle Petryszak <6314611+ProjectInitiative@users.noreply.github.com> Date: Sun, 10 May 2026 02:55:14 +0000 Subject: [PATCH 7/8] chore: add --check hint to shell hook for re-running tests --- flake.nix | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flake.nix b/flake.nix index f3a86c2..5867320 100644 --- a/flake.nix +++ b/flake.nix @@ -294,6 +294,8 @@ EOF echo " nix build .#checks.${system}.integration" echo " nix build .#checks.${system}.clippy" echo " nix build .#checks.${system}.unit-tests" + echo "" + echo " Use --check to re-run a cached result (e.g. nix build .#checks.${system}.integration --check)" ''; }; }; From 4974a6e383ec09872fede0d37b4898ed0905c514 Mon Sep 17 00:00:00 2001 From: Kyle Petryszak <6314611+ProjectInitiative@users.noreply.github.com> Date: Sun, 10 May 2026 02:56:17 +0000 Subject: [PATCH 8/8] fix: correct flag from --check to --rebuild for nix build --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 5867320..c3a17ba 100644 --- a/flake.nix +++ b/flake.nix @@ -295,7 +295,7 @@ EOF echo " nix build .#checks.${system}.clippy" echo " nix build .#checks.${system}.unit-tests" echo "" - echo " Use --check to re-run a cached result (e.g. nix build .#checks.${system}.integration --check)" + echo " Use --rebuild to re-run a cached result (e.g. nix build .#checks.${system}.integration --rebuild)" ''; }; };