contrib/plugins: add mTLS binary cache store plugin

Adds an example plugin implementing an mTLS-enabled binary cache store
(https+mtls:// scheme) using client certificates for authentication.

Darwin fix: don't link liblix* into plugins (host resolves symbols at
runtime via dynamic_lookup). Explicitly link curl so it binds to
Nix-store libcurl, not /usr/lib/libcurl. This prevents the plugin's
curl_easy_setopt calls from operating on the wrong libcurl instance.

Test portability: BSD sed -i wrapper, OpenSSL -sha256 for cert signing,
redirect test server output to log file.

Change-Id: I652b987d3ac45e31df50ff4ba1f523294438c2b6
This commit is contained in:
Raito Bezarius
2026-01-21 22:50:35 +00:00
committed by Niko Klanecek
parent 7068cbf010
commit 728d2bfee7
14 changed files with 449 additions and 8 deletions
+22
View File
@@ -0,0 +1,22 @@
# Darwin: don't link liblix* into plugins (host process provides them at runtime).
# Explicitly link curl so it binds to Nix-store libcurl, not /usr/lib/libcurl.
if is_darwin
plugin_deps = [
liblixutil.partial_dependency(includes : true, compile_args : true),
liblixstore.partial_dependency(includes : true, compile_args : true),
liblixexpr.partial_dependency(includes : true, compile_args : true),
liblixfetchers.partial_dependency(includes : true, compile_args : true),
curl,
]
else
plugin_deps = [liblixutil, liblixstore, liblixexpr, liblixfetchers, curl]
endif
plugin_mtls_store = shared_module(
'plugin_mtls_store',
'plugin_mtls_store.cc',
dependencies : plugin_deps,
install : false,
build_by_default : true,
link_args : is_darwin ? shared_module_link_args : strict_shared_module_link_args,
)
@@ -0,0 +1,14 @@
R"(
**Store URL format**: `https+mtls://...`
This store allows a binary cache to be accessed via HTTPS with mutual TLS (client certificate authentication).
Both parameters are required:
- `tls-certificate`, a path to the TLS client certificate
- `tls-private-key`, a path to the TLS private key backing the client certificate
If you don't need mTLS, use `https://` instead.
)"
+102
View File
@@ -0,0 +1,102 @@
#include "lix/libstore/store-api.hh"
#include "lix/libutil/config.hh"
#include "lix/libstore/http-binary-cache-store.hh"
#include <stdlib.h>
#include <curl/curl.h>
namespace nix {
struct mTLSBinaryCacheStoreConfig : HttpBinaryCacheStoreConfig
{
using HttpBinaryCacheStoreConfig::HttpBinaryCacheStoreConfig;
const std::string name() override
{
return "mTLS HTTP Binary Cache Store";
}
std::string doc() override
{
return
#include "mtls-http-binary-cache-store.md"
;
}
PathsSetting<nix::Path> tlsCertificate{
this,
"",
"tls-certificate",
"Path of the TLS client certificate in PEM format as expected by CURLOPT_SSLCERT"
};
PathsSetting<nix::Path> tlsKey{
this,
"",
"tls-private-key",
"Path of the TLS client certificate private key in PEM format as expected by CURLOPT_SSLKEY"
};
};
struct mTLSBinaryCacheStoreImpl : public HttpBinaryCacheStore
{
struct Keyring
{
nix::Path tlsCertificate;
nix::Path tlsKey;
};
mTLSBinaryCacheStoreConfig config_;
std::shared_ptr<Keyring> keyring;
mTLSBinaryCacheStoreConfig & config() override
{
return config_;
}
const mTLSBinaryCacheStoreConfig & config() const override
{
return config_;
}
mTLSBinaryCacheStoreImpl(
const std::string & uriScheme, const Path & _cacheUri, mTLSBinaryCacheStoreConfig config
)
: Store(config)
, HttpBinaryCacheStore("https", _cacheUri, config)
, config_(std::move(config))
, keyring(std::make_shared<Keyring>(config_.tlsCertificate.get(), config_.tlsKey.get()))
{
}
FileTransferOptions makeOptions(Headers && headers = {}) override
{
auto options = HttpBinaryCacheStore::makeOptions(std::move(headers));
auto baseExtraSetup = std::move(options.extraSetup);
auto keyring = this->keyring;
options.extraSetup = [keyring, baseExtraSetup{std::move(baseExtraSetup)}](CURL * req) {
if (baseExtraSetup) {
baseExtraSetup(req);
}
const bool haveCert = !keyring->tlsCertificate.empty();
const bool haveKey = !keyring->tlsKey.empty();
if (!(haveCert && haveKey)) {
throw Error("https+mtls requires both tls-certificate and tls-private-key");
}
curl_easy_setopt(req, CURLOPT_SSLCERT, keyring->tlsCertificate.c_str());
curl_easy_setopt(req, CURLOPT_SSLKEY, keyring->tlsKey.c_str());
};
return options;
}
static std::set<std::string> uriSchemes()
{
return {"https+mtls"};
}
};
}
extern "C" void nix_plugin_entry()
{
nix::StoreImplementations::add<nix::mTLSBinaryCacheStoreImpl, nix::mTLSBinaryCacheStoreConfig>();
}
+6
View File
@@ -198,6 +198,9 @@ nan-git:
ncfavier:
github: ncfavier
nkk0:
github: nkk0
not-my-profile:
display_name: Martin Fischer
github: not-my-profile
@@ -276,6 +279,9 @@ vigress8:
forgejo: vigress8
github: vigress8
vlaci:
github: vlaci
vlinkz:
display_name: Victor Fuentes
forgejo: vlinkz
+42
View File
@@ -0,0 +1,42 @@
---
synopsis: "mTLS store connections via a plugin"
issues: []
cls: [3754, 3696, 3697, 3698]
category: Improvements
credits: [raito, horrors, mic92, vlaci, nkk0]
---
To support use cases requiring mutual TLS (mTLS) authentication when connecting
to remote Nix stores, e.g. private stores, we have introduced a **contributed**
mTLS plugin extending the Lix store interface.
This design follows an extensibility model which was brought up [by a proposal
of making Kerberos authentication possible in Lix
directly](https://gerrit.lix.systems/c/lix/+/3637).
This mTLS plugin serves as a concrete example of how store connection
mechanisms can be modularized through external plugins, without extending Lix
core. This idea can be generalized to integrate automatic certificate renewal
or advanced integrations with secrets engine or posture checks.
It enables custom TLS client certificates to be used for authenticating against
a remote store that enforces mTLS.
To use the plugin, configure Lix manually by setting in your `nix.conf`:
```
plugin-files = /a/path/to/libplugin_mtls_store.so
```
Currently, this must be done explicitly. In the future, Nixpkgs will provide a
mechanism to reference an up-to-date and curated set of plugins automatically.
Making plugins easily consumable outside of Nixpkgs (e.g., from external plugin
registries or binary distributions) remains an open question and will require
further design.
Contributed plugins come with significantly reduced **stability** and
**maintenance** guarantees compared to the Lix core. We encourage users who
depend on a given plugin to take on maintenance responsibilities and apply for
ownership within the Lix mono-repository. These plugins are subject to removal
at any time.
+14 -4
View File
@@ -114,6 +114,7 @@ endif
enable_nix_eval_jobs = get_option('nix-eval-jobs')
enable_tests = get_option('enable-tests')
enable_contrib_plugins = get_option('enable-contrib-plugins')
tests_args = []
@@ -238,13 +239,18 @@ endif
# variable is here instead.
# This corresponds to the $(1)_ALLOW_UNDEFINED option from the Make buildsystem.
# Mostly this is load-bearing on the plugin tests defined in tests/functional/plugins/meson.build.
#
# On Darwin, plugins are two-level namespace bundles. Using -Wl,-undefined,dynamic_lookup
# allows unresolved symbols to be resolved from the host process at runtime, avoiding the
# symbol binding issues that occur with -flat_namespace (where plugin curl_* calls could
# resolve to /usr/lib/libcurl instead of the Nix-store libcurl used by the host).
shared_module_link_args = []
# This is a stricter additional set of link flags (for non-plugin shared modules).
strict_shared_module_link_args = []
if is_darwin
shared_module_link_args += ['-undefined', 'suppress', '-flat_namespace']
shared_module_link_args += ['-Wl,-undefined,dynamic_lookup']
elif is_linux
# -Wl,-z,defs is the equivalent, but a comment in the Make buildsystem says that breaks
# Clang sanitizers on Linux.
# FIXME(Qyriad): is that true?
strict_shared_module_link_args += ['-Wl,-z,defs']
endif
configdata = configuration_data()
@@ -718,6 +724,10 @@ if enable_tests
subdir('tests/functional2')
endif
if enable_contrib_plugins
subdir('contrib/plugins')
endif
subdir('meson/clang-tidy')
subproject('nix-eval-jobs', required : enable_nix_eval_jobs)
+4
View File
@@ -40,6 +40,10 @@ option('enable-tests', type : 'boolean', value : true,
description : 'whether to enable tests or not (requires rapidcheck and gtest)',
)
option('enable-contrib-plugins', type : 'boolean', value : true,
description : 'whether to build contributed plugins'
)
option('tests-color', type : 'boolean', value : true,
description : 'set to false to disable color output in gtest',
)
+1 -1
View File
@@ -36,7 +36,7 @@ pre-commit-run {
enable = true;
package = pkgs.llvmPackages.libclang.python;
entry = "${pkgs.llvmPackages.libclang.python}/bin/git-clang-format --binary ${pkgs.llvmPackages.clang-tools}/bin/clang-format";
files = "^(lix/|tests/)";
files = "^(lix/|tests/|contrib/plugins/)";
types = [
"c++"
"file"
+3
View File
@@ -294,6 +294,7 @@ stdenv.mkDerivation (finalAttrs: {
./doc
./lix
./misc
./contrib/plugins
./COPYING
]
++ lib.optionals lintInsteadOfBuild [ ./.clang-tidy ]
@@ -395,6 +396,8 @@ stdenv.mkDerivation (finalAttrs: {
yq
lsof
zstd
# For mTLS tests
curl
]
++ lib.optional useLld lldBintools
++ lib.optional hostPlatform.isLinux util-linuxMinimal
@@ -242,9 +242,20 @@ buggyNeedLocalStore() {
needLocalStore "$1"
}
# BSD/macOS sed -i requires '' as separate arg; GNU sed does not.
sedInPlace() {
local expr="$1"
local file="$2"
if sed --version >/dev/null 2>&1; then
sed -i -e "$expr" "$file"
else
sed -i '' -e "$expr" "$file"
fi
}
enableFeatures() {
local features="$1"
sed -i 's/experimental-features .*/& '"$features"'/' "$NIX_CONF_DIR"/nix.conf
sedInPlace 's/experimental-features .*/& '"$features"'/' "$NIX_CONF_DIR"/nix.conf
}
runinpty() {
@@ -254,7 +265,7 @@ runinpty() {
# Add a plugin to `plugin-files` configuration.
loadPlugin() {
local plugin_path="$1"
sed -i 's|plugin-files .*|& '"$plugin_path"'|' "$NIX_CONF_DIR"/nix.conf
sedInPlace 's|plugin-files .*|& '"$plugin_path"'|' "$NIX_CONF_DIR"/nix.conf
}
loadContribPlugin() {
+1 -1
View File
@@ -10,7 +10,7 @@ if test -d "$TEST_ROOT"; then
killDaemon
rm -rf "$TEST_ROOT"
fi
mkdir "$TEST_ROOT"
mkdir -p "$TEST_ROOT"
mkdir "$NIX_STORE_DIR"
mkdir "$NIX_LOCALSTATE_DIR"
+6
View File
@@ -156,6 +156,12 @@ if get_option('default_library') != 'static'
functional_tests_scripts += ['plugins.sh']
endif
if get_option('default_library') != 'static' and get_option('enable-contrib-plugins')
functional_tests_scripts += [
'mtls-substituter-ssl-client-cert.sh'
]
endif
# TODO(Qyriad): this will hopefully be able to be removed when we remove the autoconf+Make
# buildsystem. See the comments at the top of setup-functional-tests.py for why this is here.
meson.add_install_script(
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
import http.server
import ssl
import socketserver
import sys
import os
import argparse
import textwrap
from typing import Any
class NixCacheHandler(http.server.BaseHTTPRequestHandler):
protocol_version: str = 'HTTP/1.1'
def do_GET(self) -> None:
# Get client certificate information
try:
client_cert: dict[str, Any] | None = self.request.getpeercert()
except Exception as e:
print(f"Error getting client certificate: {e}", file=sys.stderr)
self.send_error(403, "Invalid client certificate")
return
if not client_cert:
self.send_error(403, "No client certificate provided")
return
# Additional validation - check if certificate chain is valid
subject: tuple[tuple[tuple[str, str], ...], ...] | None = client_cert.get('subject')
if not subject:
self.send_error(403, "Invalid client certificate: No subject")
return
# Log client info
print(f"Client connected: {subject}", file=sys.stderr)
print(f"Path requested: {self.path}", file=sys.stderr)
# Handle nix-cache-info endpoint
if self.path == '/nix-cache-info':
self.send_response(200)
self.send_header('Content-Type', 'text/plain')
self.send_header('Connection', 'close') # Explicitly close after response
test_root: str | None = os.environ.get('TEST_ROOT')
if not test_root:
store_root: str = '/nix/store'
else:
store_root = os.path.join(test_root, 'store')
# Nix cache info format
cache_info: str = textwrap.dedent(f"""\
StoreDir: {store_root}
WantMassQuery: 1
Priority: 30
""")
self.send_header('Content-Length', str(len(cache_info)))
self.end_headers()
self.wfile.write(cache_info.encode())
self.wfile.flush() # Ensure data is sent
# Handle .narinfo requests
elif self.path.endswith('.narinfo'):
# Return 404 for all narinfo requests (empty cache)
self.send_response(404)
self.send_header('Content-Length', '0')
self.send_header('Connection', 'close')
self.end_headers()
else:
self.send_response(404)
self.send_header('Content-Length', '0')
self.send_header('Connection', 'close')
self.end_headers()
def log_message(self, format: str, *args: Any) -> None:
# Suppress standard logging
pass
def run_server(port_fifo_path: str, certfile: str, keyfile: str, ca_certfile: str) -> None:
# Create SSL context
context: ssl.SSLContext = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
context.load_cert_chain(certfile=certfile, keyfile=keyfile)
context.verify_mode = ssl.VerifyMode.CERT_REQUIRED
context.check_hostname = False # We're not checking hostnames for client certs
context.load_verify_locations(cafile=ca_certfile)
# Bind to a free port
with socketserver.TCPServer(("localhost", 0), NixCacheHandler) as httpd:
port = httpd.server_address[1] # Extract chosen port
# Wrap with TLS
httpd.socket = context.wrap_socket(httpd.socket, server_side=True)
# Write the port to the FIFO
with open(port_fifo_path, 'w') as fifo:
fifo.write(f"{port}\n")
fifo.flush()
print(f"Server running on port {port}", file=sys.stderr)
try:
httpd.serve_forever()
except KeyboardInterrupt:
httpd.shutdown()
if __name__ == "__main__":
parser: argparse.ArgumentParser = argparse.ArgumentParser(description='Nix binary cache server with SSL client verification')
parser.add_argument('--port-fifo', type=str, required=True, help="FIFO where to inform about the port taken")
parser.add_argument('--cert', required=True, help='Server certificate file')
parser.add_argument('--key', required=True, help='Server private key file')
parser.add_argument('--ca-cert', required=True, help='CA certificate for client verification')
args: argparse.Namespace = parser.parse_args()
run_server(args.port_fifo, args.cert, args.key, args.ca_cert)
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# shellcheck source=common.sh
source common.sh
# Load the mTLS plugin for these tests.
loadContribPlugin "mtls_store"
# Generate test certificates using EC keys for faster generation
# Generate CA with EC key (with proper CA extensions for OpenSSL 3.x)
openssl ecparam -genkey -name prime256v1 -out "$TEST_ROOT/ca.key" 2>/dev/null
openssl req -new -x509 -days 1 -key "$TEST_ROOT/ca.key" -out "$TEST_ROOT/ca.crt" \
-subj "/C=US/ST=Test/L=Test/O=TestCA/CN=Test CA" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign" 2>/dev/null
# Generate server certificate with EC key
openssl ecparam -genkey -name prime256v1 -out "$TEST_ROOT/server.key" 2>/dev/null
openssl req -new -key "$TEST_ROOT/server.key" -out "$TEST_ROOT/server.csr" \
-subj "/C=US/ST=Test/L=Test/O=TestServer/CN=localhost" 2>/dev/null
openssl x509 -req -days 1 -in "$TEST_ROOT/server.csr" -CA "$TEST_ROOT/ca.crt" -CAkey "$TEST_ROOT/ca.key" \
-sha256 -set_serial 01 -out "$TEST_ROOT/server.crt" 2>/dev/null
# Generate client certificate with EC key
openssl ecparam -genkey -name prime256v1 -out "$TEST_ROOT/client.key" 2>/dev/null
openssl req -new -key "$TEST_ROOT/client.key" -out "$TEST_ROOT/client.csr" \
-subj "/C=US/ST=Test/L=Test/O=TestClient/CN=Nix Test Client" 2>/dev/null
openssl x509 -req -days 1 -in "$TEST_ROOT/client.csr" -CA "$TEST_ROOT/ca.crt" -CAkey "$TEST_ROOT/ca.key" \
-sha256 -set_serial 02 -out "$TEST_ROOT/client.crt" 2>/dev/null
# Start the server and have it write its chosen port to the FIFO
FIFO_PATH="$TEST_ROOT/server-port.fifo"
mkfifo "$FIFO_PATH"
python3 "$PWD/mtls-nix-binary-cache-ssl-server.py" \
--port-fifo "$FIFO_PATH" \
--cert "$TEST_ROOT/server.crt" \
--key "$TEST_ROOT/server.key" \
--ca-cert "$TEST_ROOT/ca.crt" \
> "$TEST_ROOT/server.log" 2>&1 &
SERVER_PID=$!
# Function to stop server on exit
stopServer() {
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
rm -f "$FIFO_PATH"
}
trap stopServer EXIT
# Read port from the FIFO (waits until server writes to it) but timeouts after 5s.
if ! PORT=$(timeout 5s bash -c "read -r line < '$FIFO_PATH'; echo \"\$line\""); then
echo "Timed out waiting for server to write port to FIFO" >&2
exit 1
fi
if ! curl -sSf --cacert "$TEST_ROOT/ca.crt" --cert "$TEST_ROOT/client.crt" --key "$TEST_ROOT/client.key" \
"https://localhost:$PORT/nix-cache-info" > /dev/null; then
if kill -0 "$SERVER_PID" 2>/dev/null; then
echo "Server started but did not respond to curl" >&2
else
echo "Server failed to start" >&2
fi
exit 1
fi
# Test 1: Verify server rejects connections without client certificate
echo "Testing connection without client certificate (should fail)..." >&2
if curl -s --cacert "$TEST_ROOT/ca.crt" "https://localhost:$PORT/nix-cache-info" > /dev/null 2>&1; then
echo "FAIL: Server should have rejected connection without client cert" >&2
exit 1
fi
# Test 2: Verify server accepts connections with client certificate
echo "Testing connection with client certificate..." >&2
RESPONSE=$(curl -s --cacert "$TEST_ROOT/ca.crt" --cert "$TEST_ROOT/client.crt" --key "$TEST_ROOT/client.key" \
"https://localhost:$PORT/nix-cache-info")
if ! echo "$RESPONSE" | grepQuiet "StoreDir: "; then
echo "FAIL: Server should have accepted client certificate: $RESPONSE" >&2
exit 1
fi
# Test 3: Test Nix with SSL client certificate parameters
# Set up substituter URL with SSL parameters
sslCache="https+mtls://localhost:$PORT?tls-certificate=$TEST_ROOT/client.crt&tls-private-key=$TEST_ROOT/client.key"
# Configure Nix to trust our CA
export NIX_SSL_CERT_FILE="$TEST_ROOT/ca.crt"
# Test nix store info
nix store ping --store "$sslCache" --json # | jq -e '.url' | grepQuiet "https://localhost:$PORT"
# Test 4: Verify incorrect client certificate is rejected
# Generate a different client cert not signed by our CA (also using EC)
openssl ecparam -genkey -name prime256v1 -out "$TEST_ROOT/wrong.key" 2>/dev/null
openssl req -new -x509 -days 1 -key "$TEST_ROOT/wrong.key" -out "$TEST_ROOT/wrong.crt" \
-subj "/C=US/ST=Test/L=Test/O=Wrong/CN=Wrong Client" 2>/dev/null
wrongCache="https+mtls://localhost:$PORT?tls-certificate=$TEST_ROOT/wrong.crt&tls-private-key=$TEST_ROOT/wrong.key"
rm -rf "$TEST_HOME"
# This should fail
if nix store ping --download-attempts 0 --store "$wrongCache"; then
echo "FAIL: Should have rejected wrong certificate" >&2
exit 1
fi