summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authoromnom62 <omnom62@outlook.com>2026-08-11 16:45:15 +1000
committerJohn Estabrook <jestabro@vyos.io>2026-08-26 13:33:34 -0500
commit0cdf7e696b4deb3c3103a596bfe4d4f167dcf4b7 (patch)
tree0d66820d71d2c193e1e7f44f73e7abfca2fa87cb
parentf3012e652edef614d4f0ed169b320106a85d83b3 (diff)
downloadvyos-1x-0cdf7e696b4deb3c3103a596bfe4d4f167dcf4b7.tar.gz
vyos-1x-0cdf7e696b4deb3c3103a596bfe4d4f167dcf4b7.zip
http-api: T8989: add mTLS client certificate authentication
Add support for mutual TLS (mTLS) authentication to the VyOS REST API. When configured, nginx requests a client certificate and verifies it against the configured CA chain. FastAPI reads the X-Client-Verify header set by nginx and bypasses API key/token authentication when the client certificate is valid. Configuration: set service https certificates ca-certificate <name> set service https certificates verify-client <optional|required> Note: requires TLSv1.2 due to nginx 1.22 TLSv1.3 post-handshake authentication limitations. TLSv1.3 support pending nginx upgrade.
-rw-r--r--data/templates/https/nginx.default.j210
-rw-r--r--interface-definitions/service_https.xml.in19
-rwxr-xr-xsrc/conf_mode/service_https.py17
-rw-r--r--src/services/api/rest/routers.py6
4 files changed, 51 insertions, 1 deletions
diff --git a/data/templates/https/nginx.default.j2 b/data/templates/https/nginx.default.j2
index 1074a997a..1022079b3 100644
--- a/data/templates/https/nginx.default.j2
+++ b/data/templates/https/nginx.default.j2
@@ -48,6 +48,12 @@ server {
ssl_prefer_server_ciphers on;
ssl_ciphers 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!3DES:!MD5:!PSK';
+{% if certificates.mtls_ca_path is vyos_defined %}
+ # mTLS - client certificate verification (TLSv1.2 required)
+ ssl_client_certificate {{ certificates.mtls_ca_path }};
+ ssl_verify_client optional;
+ ssl_verify_depth 2;
+{% endif %}
# proxy settings for HTTP API, if enabled; 503, if not
location ~ ^/(retrieve|configure|config-file|image|import-pki|container-image|generate|show|reboot|reset|poweroff|ping|traceroute|info|docs|openapi.json|redoc|graphql|renew|token) {
{% if api is vyos_defined %}
@@ -56,6 +62,10 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 600;
proxy_buffering off;
+{% if certificates.mtls_ca_path is vyos_defined %}
+ proxy_set_header X-Client-Verify $ssl_client_verify;
+ proxy_set_header X-Client-DN $ssl_client_s_dn;
+{% endif %}
{% else %}
return 503;
{% endif %}
diff --git a/interface-definitions/service_https.xml.in b/interface-definitions/service_https.xml.in
index f576444e9..d2cadeffe 100644
--- a/interface-definitions/service_https.xml.in
+++ b/interface-definitions/service_https.xml.in
@@ -199,6 +199,25 @@
#include <include/pki/ca-certificate.xml.i>
#include <include/pki/certificate.xml.i>
#include <include/pki/dh-params.xml.i>
+ <leafNode name="verify-client">
+ <properties>
+ <help>Require client certificate verification (mTLS)</help>
+ <completionHelp>
+ <list>optional required</list>
+ </completionHelp>
+ <valueHelp>
+ <format>optional</format>
+ <description>Request but do not require client certificate</description>
+ </valueHelp>
+ <valueHelp>
+ <format>required</format>
+ <description>Require valid client certificate</description>
+ </valueHelp>
+ <constraint>
+ <regex>(optional|required)</regex>
+ </constraint>
+ </properties>
+ </leafNode>
</children>
</node>
<leafNode name="tls-version">
diff --git a/src/conf_mode/service_https.py b/src/conf_mode/service_https.py
index 28985ead9..f66ce043c 100755
--- a/src/conf_mode/service_https.py
+++ b/src/conf_mode/service_https.py
@@ -112,6 +112,9 @@ def verify(https):
else:
Warning('No certificate specified, using build-in self-signed certificates. '\
'Do not use them in a production environment!')
+ if dict_search('certificates.verify_client', https) is not None:
+ if dict_search('certificates.ca_certificate', https) is None:
+ raise ConfigError('CA certificate must be configured for mTLS client verification')
# Check if server port is already in use by a different application
listen_address = ['0.0.0.0']
@@ -212,6 +215,20 @@ def generate(https):
tmp_path.update({'dh_file' : dh_path})
https['certificates'].update(tmp_path)
+ # Write mTLS CA chain if verify-client is configured
+ if dict_search('certificates.verify_client', https) and dict_search('certificates.ca_certificate', https):
+ ca_name = https['certificates']['ca_certificate']
+ pki_ca = dict_search(f'pki.ca.{ca_name}', https)
+ if pki_ca:
+ # Build full chain: intermediate + root CAs for client cert verification
+ loaded_ca_certs = {
+ load_certificate(cert_data['certificate'])
+ for cert_data in dict_search('pki.ca', https, default={}).values()
+ }
+ mtls_ca_path = os.path.join(cert_dir, f'{ca_name}_mtls_ca.pem')
+ ca_chain = '\n'.join(encode_certificate(c) for c in loaded_ca_certs)
+ write_file(mtls_ca_path, ca_chain, user=user, group=group, mode=0o644)
+ https['certificates']['mtls_ca_path'] = mtls_ca_path
render(config_file, 'https/nginx.default.j2', https)
render(systemd_override, 'https/override.conf.j2', https)
diff --git a/src/services/api/rest/routers.py b/src/services/api/rest/routers.py
index 0a43e856b..55967f57d 100644
--- a/src/services/api/rest/routers.py
+++ b/src/services/api/rest/routers.py
@@ -101,8 +101,12 @@ def check_auth(key_list, key):
return key_id
-def auth_required(data: ApiModel, x_api_key: Optional[str] = Header(None), authorization: Optional[str] = Header(None)):
+def auth_required(data: ApiModel, x_api_key: Optional[str] = Header(None), authorization: Optional[str] = Header(None), x_client_verify: Optional[str] = Header(None)):
session = SessionState()
+ # mTLS: client certificate verified by nginx against configured CA
+ if x_client_verify == 'SUCCESS':
+ session.id = 'mtls-client'
+ return
if authorization:
scheme, _, token = authorization.partition(' ')