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
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
'';
};
};
Expand Down
20 changes: 20 additions & 0 deletions nixos/module.nix
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ let
baseConfig = {
s3 = {
inherit (cfg.s3) bucket region endpoint;
extra_headers = cfg.s3.extraHeaders;
};
loft = {
upload_threads = cfg.uploadThreads;
Expand Down Expand Up @@ -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 ######
Expand All @@ -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 {
Expand Down Expand Up @@ -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"}
'';
Expand Down
91 changes: 89 additions & 2 deletions nixos/tests/integration.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -59,7 +75,7 @@
sandbox = false;
};

networking.firewall.allowedTCPPorts = [ 3900 3901 ];
networking.firewall.allowedTCPPorts = [ 3900 3901 3902 ];
};

testScript = ''
Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -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!")
'';
}
5 changes: 5 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

use anyhow::{Context, Result};
use serde::Deserialize;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};

Expand Down Expand Up @@ -102,6 +103,10 @@ pub struct S3Config {
pub endpoint: String,
pub access_key: Option<String>,
pub secret_key: Option<String>,
/// Optional extra HTTP headers to include on every S3 request.
/// Useful for authentication proxies (e.g., Cloudflare Access).
#[serde(default)]
pub extra_headers: HashMap<String, String>,
}

/// Sets the default number of upload threads if not specified.
Expand Down
54 changes: 51 additions & 3 deletions src/s3_uploader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -629,6 +650,33 @@ impl RemoteCacheStorage for S3Uploader {
}
}

#[derive(Debug)]
struct CustomHeadersInterceptor {
headers: HashMap<String, String>,
}

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<Utc>
pub trait AwsDateTimeExt {
fn to_chrono_utc(&self) -> DateTime<Utc>;
Expand Down
Loading