contrib/plugins: init with mTLS store example

This is a collection of Lix plugins that showcase how to write one for
various usecases.

The first is a mTLS store plugin that enable mTLS cache URIs
(`https+mtls://`).

We enable meson build system support for this plugin but we are not
going to distribute it in the official packaging of Lix, we will
repackage each relevant plugin downstream in Nixpkgs.

These plugins have *NO* guarantee support, they are provided as useful
references and are possibly production-ready if your usecase is simple
enough.

Reference: https://github.com/NixOS/nix/pull/13030 (this change has
resemblances but our APIs are different, the tests harness is mostly
from CppNix).

Change-Id: Ib354271981b35dff6c134b12c4748c3eaf743fcb
Co-authored-by: Jörg Thalheim <joerg@thalheim.io>
Co-authored-by: László Vaskó <1771332+vlaci@users.noreply.github.com>
Signed-off-by: Raito Bezarius <raito@lix.systems>
This commit is contained in:
Raito Bezarius
2025-07-28 23:00:44 +00:00
co-authored by Jörg Thalheim László Vaskó
parent 2d0c1e29d2
commit 569dcf9392
13 changed files with 421 additions and 5 deletions
+14
View File
@@ -0,0 +1,14 @@
plugin_mtls_store = shared_module(
'plugin_mtls_store',
'plugin_mtls_store.cc',
dependencies : [
liblixutil,
liblixstore,
liblixexpr,
liblixfetchers,
curl,
],
install : false,
build_by_default : true,
link_args : strict_shared_module_link_args,
)
@@ -0,0 +1,13 @@
R"(
**Store URL format**: `https+mtls://...`
This store allows a binary cache to be accessed via the HTTPS
protocol with mutual TLS mandated.
Two parameters can be passed to the query string:
- `tls-certificate`, a path to the TLS client certificate (optional)
- `tls-private-key`, a path to the TLS private key backing the client certificate (required)
)"
+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 an optional TLS client certificate in PEM format as expected by CURLOPT_SSLCERT"
};
PathsSetting<nix::Path> tlsKey{
this,
"",
"tls-private-key",
"Path of an 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);
}
if (!keyring->tlsCertificate.empty()) {
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
@@ -156,6 +156,9 @@ ma27:
matthewbauer:
github: matthewbauer
mic92:
github: Mic92
midnightveil:
display_name: julia
forgejo: midnightveil
@@ -243,6 +246,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]
---
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.
+9 -3
View File
@@ -125,6 +125,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 = []
@@ -224,12 +225,13 @@ is_x64 = host_machine.cpu_family() == 'x86_64'
# 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.
shared_module_link_args = []
# This is a stricter additional set of link flags.
strict_shared_module_link_args = []
if is_darwin
shared_module_link_args += ['-undefined', 'suppress', '-flat_namespace']
strict_shared_module_link_args += ['-flat_namespace']
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 = { }
@@ -663,6 +665,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
@@ -32,6 +32,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"
+5
View File
@@ -232,6 +232,7 @@ stdenv.mkDerivation (finalAttrs: {
./doc
./lix
./misc
./contrib/plugins
./COPYING
]
++ lib.optionals lintInsteadOfBuild [ ./.clang-tidy ]
@@ -309,6 +310,10 @@ stdenv.mkDerivation (finalAttrs: {
jq
yq
lsof
# For mTLS tests.
curl
openssl
python3
]
++ lib.optional hostPlatform.isLinux util-linuxMinimal
++ lib.optional (!officialRelease && buildUnreleasedNotes) build-release-notes
+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
@@ -180,6 +180,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 += [
'plugins/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(
@@ -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)
@@ -0,0 +1,105 @@
#!/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
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" 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" \
-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" \
-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/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" &
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 -k --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 -k "https://localhost:$PORT/nix-cache-info" 2>&1 | grep -q "certificate required"; then
echo "FAIL: Server should have rejected connection" >&2
exit 1
fi
# Test 2: Verify server accepts connections with client certificate
echo "Testing connection with client certificate..." >&2
RESPONSE=$(curl -v -s -k --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