From f3012e652edef614d4f0ed169b320106a85d83b3 Mon Sep 17 00:00:00 2001 From: omnom62 Date: Wed, 5 Aug 2026 09:14:32 +1000 Subject: http-api: T8989: add REST Bearer token authentication Add JWT Bearer token support to the REST API, as an additional authentication method alongside the existing form-field key and X-API-Key header. - New POST /token endpoint mints a JWT for a valid API key - auth_required() accepts Authorization: Bearer alongside existing key/X-API-Key auth - New config nodes: service https api rest authentication {expiration, secret-length} (defaults: 3600s / 32 bytes) - REST tokens use an independent signing secret from GraphQL's, since GraphQL may not be enabled on all deployments and the two subsystems have different expiry requirements - nginx location regex updated to allow /token - service_https.py default-value merge generalized to also apply to the rest node, not just graphql, so REST authentication defaults populate correctly on commit --- data/templates/https/nginx.default.j2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'data/templates/https/nginx.default.j2') diff --git a/data/templates/https/nginx.default.j2 b/data/templates/https/nginx.default.j2 index e0ebe79fc..1074a997a 100644 --- a/data/templates/https/nginx.default.j2 +++ b/data/templates/https/nginx.default.j2 @@ -49,7 +49,7 @@ server { 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'; # 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) { + 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 %} proxy_pass http://unix:/run/api.sock; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; -- cgit v1.2.3 From 0cdf7e696b4deb3c3103a596bfe4d4f167dcf4b7 Mon Sep 17 00:00:00 2001 From: omnom62 Date: Tue, 11 Aug 2026 16:45:15 +1000 Subject: 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 set service https certificates verify-client Note: requires TLSv1.2 due to nginx 1.22 TLSv1.3 post-handshake authentication limitations. TLSv1.3 support pending nginx upgrade. --- data/templates/https/nginx.default.j2 | 10 ++++++++++ interface-definitions/service_https.xml.in | 19 +++++++++++++++++++ src/conf_mode/service_https.py | 17 +++++++++++++++++ src/services/api/rest/routers.py | 6 +++++- 4 files changed, 51 insertions(+), 1 deletion(-) (limited to 'data/templates/https/nginx.default.j2') 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 #include + + + Require client certificate verification (mTLS) + + optional required + + + optional + Request but do not require client certificate + + + required + Require valid client certificate + + + (optional|required) + + + 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(' ') -- cgit v1.2.3 From c489a19e454a9be5438fc16e26e62407a0d64f49 Mon Sep 17 00:00:00 2001 From: omnom62 Date: Tue, 11 Aug 2026 17:01:58 +1000 Subject: http-api: T8989: linter fixes --- data/templates/https/nginx.default.j2 | 6 +++--- src/conf_mode/service_https.py | 8 ++++++-- src/services/api/rest/routers.py | 8 +++++++- 3 files changed, 16 insertions(+), 6 deletions(-) (limited to 'data/templates/https/nginx.default.j2') diff --git a/data/templates/https/nginx.default.j2 b/data/templates/https/nginx.default.j2 index 1022079b3..e655bd8ca 100644 --- a/data/templates/https/nginx.default.j2 +++ b/data/templates/https/nginx.default.j2 @@ -49,7 +49,7 @@ server { 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) + # mTLS - client certificate verification ssl_client_certificate {{ certificates.mtls_ca_path }}; ssl_verify_client optional; ssl_verify_depth 2; @@ -62,10 +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 %} +{% 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 %} +{% endif %} {% else %} return 503; {% endif %} diff --git a/src/conf_mode/service_https.py b/src/conf_mode/service_https.py index f66ce043c..b1381eca5 100755 --- a/src/conf_mode/service_https.py +++ b/src/conf_mode/service_https.py @@ -114,7 +114,9 @@ def verify(https): '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') + 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'] @@ -216,7 +218,9 @@ def generate(https): 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): + 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: diff --git a/src/services/api/rest/routers.py b/src/services/api/rest/routers.py index 55967f57d..0973ad145 100644 --- a/src/services/api/rest/routers.py +++ b/src/services/api/rest/routers.py @@ -101,7 +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), x_client_verify: 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': @@ -1032,6 +1037,7 @@ def traceroute_op(data: TracerouteModel): return success(res) + @router.post('/token') def token_op(data: ApiModel): session = SessionState() -- cgit v1.2.3 From 1832b2bb3e9948f8bf6dfb3be50fd10363bc3a38 Mon Sep 17 00:00:00 2001 From: omnom62 Date: Wed, 12 Aug 2026 15:22:05 +1000 Subject: http-api: T8989: address further review comments - Scope mTLS CA chain to selected CA only (not all configured CAs) - Pass verify-client value to nginx template instead of hardcoding optional - Allow mTLS-only REST configuration without API keys - Add mTLS warning when no API keys are configured --- data/templates/https/nginx.default.j2 | 2 +- src/conf_mode/service_https.py | 23 ++++++++++++++++++----- 2 files changed, 19 insertions(+), 6 deletions(-) (limited to 'data/templates/https/nginx.default.j2') diff --git a/data/templates/https/nginx.default.j2 b/data/templates/https/nginx.default.j2 index e655bd8ca..8b9bb4e23 100644 --- a/data/templates/https/nginx.default.j2 +++ b/data/templates/https/nginx.default.j2 @@ -51,7 +51,7 @@ server { {% if certificates.mtls_ca_path is vyos_defined %} # mTLS - client certificate verification ssl_client_certificate {{ certificates.mtls_ca_path }}; - ssl_verify_client optional; + ssl_verify_client {{ certificates.verify_client }}; ssl_verify_depth 2; {% endif %} # proxy settings for HTTP API, if enabled; 503, if not diff --git a/src/conf_mode/service_https.py b/src/conf_mode/service_https.py index b1381eca5..093209304 100755 --- a/src/conf_mode/service_https.py +++ b/src/conf_mode/service_https.py @@ -156,11 +156,18 @@ def verify(https): # If only key-based methods are enabled, # fail the commit if no valid key configurations are found - if (not valid_keys_exist) and (not jwt_auth): - raise ConfigError('At least one HTTPS API key is required unless GraphQL token authentication is enabled!') + mtls_auth = dict_search('certificates.verify_client', https) is not None + if (not valid_keys_exist) and (not jwt_auth) and (not mtls_auth): + raise ConfigError( + 'At least one HTTPS API key is required unless GraphQL token or mTLS authentication is enabled!' + ) if (not valid_keys_exist) and jwt_auth: Warning(f'API keys are not configured: classic (non-GraphQL) API will be unavailable!') + if (not valid_keys_exist) and mtls_auth and not jwt_auth: + Warning( + 'API keys are not configured: only mTLS client certificate authentication will be available for the REST API!' + ) return None @@ -224,15 +231,21 @@ def generate(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 = { + # Build chain for the selected CA only (not all configured CAs) + # to prevent any other CA from authenticating REST API clients + all_ca_certs = { load_certificate(cert_data['certificate']) for cert_data in dict_search('pki.ca', https, default={}).values() } + selected_ca = load_certificate(pki_ca['certificate']) + ca_chain_certs = find_chain(selected_ca, all_ca_certs) 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) + ca_chain = '\n'.join(encode_certificate(c) for c in ca_chain_certs) write_file(mtls_ca_path, ca_chain, user=user, group=group, mode=0o644) https['certificates']['mtls_ca_path'] = mtls_ca_path + https['certificates']['verify_client'] = dict_search( + 'certificates.verify_client', https + ) render(config_file, 'https/nginx.default.j2', https) render(systemd_override, 'https/override.conf.j2', https) -- cgit v1.2.3 From 99decdac737629507b88c4d0390344a981a181ab Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Tue, 1 Sep 2026 21:06:13 -0500 Subject: http-api: T8989: translate to correct term in nginx directive --- data/templates/https/nginx.default.j2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'data/templates/https/nginx.default.j2') diff --git a/data/templates/https/nginx.default.j2 b/data/templates/https/nginx.default.j2 index 8b9bb4e23..0f1a79ad7 100644 --- a/data/templates/https/nginx.default.j2 +++ b/data/templates/https/nginx.default.j2 @@ -51,7 +51,7 @@ server { {% if certificates.mtls_ca_path is vyos_defined %} # mTLS - client certificate verification ssl_client_certificate {{ certificates.mtls_ca_path }}; - ssl_verify_client {{ certificates.verify_client }}; + ssl_verify_client {{ "on" if certificates.verify_client == "required" else certificates.verify_client }}; ssl_verify_depth 2; {% endif %} # proxy settings for HTTP API, if enabled; 503, if not -- cgit v1.2.3