diff --git a/src/libstore/filetransfer.cc b/src/libstore/filetransfer.cc index 7e29d00e6c0..8a022221859 100644 --- a/src/libstore/filetransfer.cc +++ b/src/libstore/filetransfer.cc @@ -351,7 +351,9 @@ struct curlFileTransfer : public FileTransfer curl_easy_setopt(req, CURLOPT_PIPEWAIT, 1); #endif #if LIBCURL_VERSION_NUM >= 0x072f00 - if (fileTransferSettings.enableHttp2) + // Our writeCallbackWrapper does not support rewinding which breaks + // negotiate/kerberos auth over http/2. + if (fileTransferSettings.enableHttp2 && request.authmethod != HttpAuthMethod::NEGOTIATE) curl_easy_setopt(req, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_2TLS); else curl_easy_setopt(req, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1); @@ -397,6 +399,14 @@ struct curlFileTransfer : public FileTransfer curl_easy_setopt(req, CURLOPT_SOCKOPTFUNCTION, cloexec_callback); #endif + curl_easy_setopt(req, CURLOPT_HTTPAUTH, request.authmethod); + if (request.authmethod == HttpAuthMethod::NEGOTIATE) { + curl_easy_setopt(req, CURLOPT_USERNAME, ""); + curl_easy_setopt(req, CURLOPT_PASSWORD, ""); + } else if (request.authmethod == HttpAuthMethod::BEARER) { + curl_easy_setopt(req, CURLOPT_XOAUTH2_BEARER, request.bearer_token.c_str()); + } + curl_easy_setopt(req, CURLOPT_CONNECTTIMEOUT, fileTransferSettings.connectTimeout.get()); curl_easy_setopt(req, CURLOPT_LOW_SPEED_LIMIT, 1L); diff --git a/src/libstore/http-binary-cache-store.cc b/src/libstore/http-binary-cache-store.cc index e44d146b9ee..69fb058e293 100644 --- a/src/libstore/http-binary-cache-store.cc +++ b/src/libstore/http-binary-cache-store.cc @@ -9,6 +9,23 @@ namespace nix { MakeError(UploadToHTTP, Error); +HttpAuthMethod parseHttpAuthMethod(const std::string &str) { + static const std::map map = { + {"none", HttpAuthMethod::NONE}, + {"basic", HttpAuthMethod::BASIC}, + {"digest", HttpAuthMethod::DIGEST}, + {"negotiate", HttpAuthMethod::NEGOTIATE}, + {"ntlm", HttpAuthMethod::NTLM}, + {"bearer", HttpAuthMethod::BEARER}, + {"any", HttpAuthMethod::ANY}, + {"anysafe", HttpAuthMethod::ANYSAFE}}; + auto it = map.find(str); + if (it == map.end()) { + throw UsageError("option authmethod has invalid value '%s'", str); + } + return it->second; +} + StringSet HttpBinaryCacheStoreConfig::uriSchemes() { @@ -152,11 +169,13 @@ class HttpBinaryCacheStore : FileTransferRequest makeRequest(const std::string & path) { - return FileTransferRequest( + auto request = FileTransferRequest( hasPrefix(path, "https://") || hasPrefix(path, "http://") || hasPrefix(path, "file://") ? path : config->cacheUri + "/" + path); - + request.authmethod = parseHttpAuthMethod(config->authmethod); + request.bearer_token = config->bearer_token; + return request; } void getFile(const std::string & path, Sink & sink) override diff --git a/src/libstore/include/nix/store/filetransfer.hh b/src/libstore/include/nix/store/filetransfer.hh index 745aeb29ee3..bbcd702df4d 100644 --- a/src/libstore/include/nix/store/filetransfer.hh +++ b/src/libstore/include/nix/store/filetransfer.hh @@ -1,6 +1,8 @@ #pragma once ///@file +#include "http-binary-cache-store.hh" + #include #include @@ -68,6 +70,8 @@ struct FileTransferRequest bool verifyTLS = true; bool head = false; bool post = false; + HttpAuthMethod authmethod = HttpAuthMethod::BASIC; + std::string bearer_token; size_t tries = fileTransferSettings.tries; unsigned int baseRetryTimeMs = RETRY_TIME_MS_DEFAULT; ActivityId parentAct; diff --git a/src/libstore/include/nix/store/http-binary-cache-store.hh b/src/libstore/include/nix/store/http-binary-cache-store.hh index 66ec5f8d254..9ce2a302757 100644 --- a/src/libstore/include/nix/store/http-binary-cache-store.hh +++ b/src/libstore/include/nix/store/http-binary-cache-store.hh @@ -1,7 +1,24 @@ -#include "nix/store/binary-cache-store.hh" +#pragma once +///@file + +#include "nix/util/types.hh" +#include + +#include "binary-cache-store.hh" namespace nix { +enum struct HttpAuthMethod : unsigned long { + NONE = CURLAUTH_NONE, + BASIC = CURLAUTH_BASIC, + DIGEST = CURLAUTH_DIGEST, + NEGOTIATE = CURLAUTH_NEGOTIATE, + NTLM = CURLAUTH_NTLM, + BEARER = CURLAUTH_BEARER, + ANY = CURLAUTH_ANY, + ANYSAFE = CURLAUTH_ANYSAFE +}; + struct HttpBinaryCacheStoreConfig : std::enable_shared_from_this, virtual Store::Config, BinaryCacheStoreConfig @@ -20,6 +37,15 @@ struct HttpBinaryCacheStoreConfig : std::enable_shared_from_this authmethod{this, "basic", "authmethod", + R"( + libcurl auth method to use (`none`, `basic`, `digest`, `bearer`, `negotiate`, `ntlm`, `any`, or `anysafe`). + See https://curl.se/libcurl/c/CURLOPT_HTTPAUTH.html for more info. + )"}; + + const Setting bearer_token{this, "", "bearer-token", + "Bearer token to use for authentication. Requires `authmethod` to be set to `bearer`."}; + static std::string doc(); ref openStore() const override; diff --git a/src/libstore/package.nix b/src/libstore/package.nix index 775776139ae..0028ea702a1 100644 --- a/src/libstore/package.nix +++ b/src/libstore/package.nix @@ -72,6 +72,7 @@ mkMesonLibrary (finalAttrs: { propagatedBuildInputs = [ nix-util nlohmann_json + curl ]; mesonFlags = diff --git a/tests/nixos/default.nix b/tests/nixos/default.nix index f0b1a886565..880960c3ae3 100644 --- a/tests/nixos/default.nix +++ b/tests/nixos/default.nix @@ -189,6 +189,8 @@ in s3-binary-cache-store = runNixOSTestFor "x86_64-linux" ./s3-binary-cache-store.nix; + http-binary-cache-auth = runNixOSTestFor "x86_64-linux" ./http-binary-cache-auth.nix; + fsync = runNixOSTestFor "x86_64-linux" ./fsync.nix; cgroups = runNixOSTestFor "x86_64-linux" ./cgroups; diff --git a/tests/nixos/http-binary-cache-auth.nix b/tests/nixos/http-binary-cache-auth.nix new file mode 100644 index 00000000000..2bf2991d0b2 --- /dev/null +++ b/tests/nixos/http-binary-cache-auth.nix @@ -0,0 +1,245 @@ +# Run with: +# cd nixpkgs +# nix-build -A nixosTests.http-binary-cache-auth + +# Test HTTP binary cache authentication methods (Basic, Bearer) +{ + lib, + config, + nixpkgs, + ... +}: + +let + pkgs = config.nodes.client.nixpkgs.pkgs; + + # Test package to copy + testPkg = pkgs.hello; + + # Basic auth credentials + basicUser = "testuser"; + basicPassword = "testpassword"; + + # Bearer token for bearer auth test + bearerToken = "test-bearer-token-12345"; + +in +{ + name = "http-binary-cache-auth"; + + nodes = { + # HTTP Binary Cache Server with authentication + server = + { config, pkgs, ... }: + { + virtualisation.writableStore = true; + virtualisation.additionalPaths = [ testPkg ]; + + networking.firewall.allowedTCPPorts = [ + 80 + 8080 + 8081 + ]; + + # Nginx with different auth methods + services.nginx = { + enable = true; + + # For basic auth testing + virtualHosts."basic-auth" = { + listen = [ + { + addr = "0.0.0.0"; + port = 8080; + } + ]; + + locations."/" = { + root = "/var/cache/nix-cache"; + extraConfig = '' + autoindex on; + auth_basic "Nix Binary Cache"; + auth_basic_user_file /etc/nginx/htpasswd; + ''; + }; + }; + + # For bearer token auth testing + virtualHosts."bearer-auth" = { + listen = [ + { + addr = "0.0.0.0"; + port = 8081; + } + ]; + + locations."/" = { + root = "/var/cache/nix-cache"; + extraConfig = '' + autoindex on; + set $auth_header $http_authorization; + if ($auth_header != "Bearer ${bearerToken}") { + return 401; + } + ''; + }; + }; + + # No auth (for control test) + virtualHosts."no-auth" = { + listen = [ + { + addr = "0.0.0.0"; + port = 80; + } + ]; + + locations."/" = { + root = "/var/cache/nix-cache"; + extraConfig = '' + autoindex on; + ''; + }; + }; + }; + + environment.systemPackages = [ pkgs.curl ]; + + nix.settings.substituters = lib.mkForce [ ]; + }; + + # Client machine + client = + { config, pkgs, ... }: + { + virtualisation.writableStore = true; + + environment.systemPackages = [ pkgs.curl ]; + + nix.settings.substituters = lib.mkForce [ ]; + }; + }; + + testScript = + { nodes }: + '' + import time + + # fmt: off + start_all() + + # Wait for services to be ready + server.wait_for_unit("nginx.service") + server.wait_for_open_port(80) + server.wait_for_open_port(8080) + server.wait_for_open_port(8081) + + # Set up basic auth htpasswd file + server.succeed(""" + echo '${basicPassword}' | htpasswd -i -c /etc/nginx/htpasswd ${basicUser} + systemctl reload nginx + """) + + # Create binary cache on server + server.succeed(""" + mkdir -p /var/cache/nix-cache + nix copy --to file:///var/cache/nix-cache ${testPkg} + """) + + # Test that the binary cache was created properly + server.succeed("ls -la /var/cache/nix-cache/") + server.succeed("ls -la /var/cache/nix-cache/nar/") + + # Test 1: No authentication (control) + print("Testing no authentication...") + client.succeed("curl -f http://server/") + client.succeed(""" + nix copy --from 'http://server' ${testPkg} --no-check-sigs + """) + client.succeed("nix path-info ${testPkg}") + client.succeed("nix-store --delete ${testPkg}") + + # Test 2: Basic authentication + print("Testing Basic authentication...") + + # Verify we can't access without auth + client.fail("curl -f http://server:8080/") + + # Test curl with basic auth works + client.succeed("curl -f -u ${basicUser}:${basicPassword} http://server:8080/") + + # Test nix with basic auth (default authmethod) + # Note: Nix uses libcurl which reads credentials from .netrc + client.succeed(""" + cat > ~/.netrc << EOF + machine server + login ${basicUser} + password ${basicPassword} + EOF + chmod 600 ~/.netrc + """) + + client.succeed(""" + nix copy --from 'http://server:8080' ${testPkg} --no-check-sigs + """) + + client.succeed("nix path-info ${testPkg}") + client.succeed("nix-store --delete ${testPkg}") + client.succeed("rm ~/.netrc") + + # Test 3: Bearer token authentication + print("Testing Bearer token authentication...") + + # Verify we can't access without auth + client.fail("curl -f http://server:8081/") + + # Test curl with bearer token + client.succeed('curl -f -H "Authorization: Bearer ${bearerToken}" http://server:8081/') + + # Test nix with bearer auth + client.succeed(""" + nix copy --from 'http://server:8081?authmethod=bearer&bearer-token=${bearerToken}' ${testPkg} --no-check-sigs + """) + + client.succeed("nix path-info ${testPkg}") + client.succeed("nix-store --delete ${testPkg}") + + # Test 4: Wrong bearer token should fail + print("Testing bearer token failure...") + client.fail(""" + nix copy --from 'http://server:8081?authmethod=bearer&bearer-token=wrong-token' ${testPkg} --no-check-sigs 2>&1 + """) + + # Test 5: Using wrong auth method should fail + print("Testing auth method mismatch...") + # Try basic auth on bearer endpoint + client.fail(""" + cat > ~/.netrc << EOF + machine server + login ${basicUser} + password ${basicPassword} + EOF + chmod 600 ~/.netrc + nix copy --from 'http://server:8081?authmethod=basic' ${testPkg} --no-check-sigs 2>&1 + """) + client.succeed("rm ~/.netrc") + + # Test 6: Test that authmethod parameter is parsed correctly + print("Testing authmethod parameter parsing...") + + # Valid auth methods should not error on parsing + for method in ["basic", "digest", "negotiate", "ntlm", "bearer", "any", "anysafe"]: + # We expect these to fail due to auth, but not due to parsing + client.fail(f""" + timeout 5 nix copy --from 'http://server:8080?authmethod={method}' ${testPkg} --no-check-sigs 2>&1 | grep -v "unknown auth method" + """) + + # Invalid auth method should error + result = client.fail(""" + nix copy --from 'http://server:8080?authmethod=invalid' ${testPkg} --no-check-sigs 2>&1 + """) + assert "unknown auth method 'invalid'" in result + + print("All authentication tests passed!") + ''; +}