diff --git a/CHANGES.txt b/CHANGES.txt index eac4d698..533c706b 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -5,6 +5,8 @@ Changes for crash Unreleased ========== +- Honor ``?verify_ssl=`` in the host URLs to toggle TLS certificate verification. The CLI ``--verify-ssl`` flag overrides the URL param. Closes #482. + 2026/02/09 0.32.0 ================= diff --git a/docs/run.rst b/docs/run.rst index 351f48c9..e07de8c6 100644 --- a/docs/run.rst +++ b/docs/run.rst @@ -42,6 +42,15 @@ The ``crash`` executable supports multiple command-line options: | | ```` can be a single host, or it can | | | be a space separated list of hosts. | | | | +| | Each host may also be given as a full URL, | +| | e.g. | +| | ``https://user:pw@node:4200/?verify_ssl=0``. | +| | Userinfo is used for authentication and the | +| | ``verify_ssl`` query parameter toggles TLS | +| | verification. Explicit CLI flags | +| | (``--username``, ``--verify-ssl``, …) | +| | always override values taken from the URL. | +| | | | | If multiple hosts are specified, Crash will | | | attempt to connect to all of them. The | | | command will succeed if at least one | @@ -153,6 +162,13 @@ Here, we're using: - ``~/.certs/client.key`` as the client certificate key - ``~/.certs/server-ca.crt`` as the server CA certificate +The same connection settings can also be given inline via the host URL: + +.. code-block:: console + + sh$ crash --hosts "https://crate:secret@node1.example.com:4200/?verify_ssl=false" \ + -c "SELECT * FROM sys.nodes" + .. _user-conf-dir: User configuration directory diff --git a/src/crate/crash/command.py b/src/crate/crash/command.py index 156f9f30..e46e7bdc 100644 --- a/src/crate/crash/command.py +++ b/src/crate/crash/command.py @@ -32,6 +32,7 @@ from getpass import getpass from operator import itemgetter from typing import Optional, Union +from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse import sqlparse import urllib3 @@ -147,8 +148,10 @@ def _conf_or_default(key, value): '--verify-ssl', choices=(True, False), type=boolean, - default=True, - help='Enable or disable the verification of the server SSL certificate' + default=None, + help='Enable or disable the verification of the server SSL certificate ' + '(default: enabled). Can also be set as a `verify_ssl` query ' + 'parameter on a host URL, with this cli option having priority over the URL params.' ) parser.add_argument('--cert-file', type=file_with_permissions, metavar='FILENAME', help='use FILENAME as the client certificate file') @@ -563,6 +566,35 @@ def host_and_port(host_or_port): return host_or_port + ':4200' +def extract_url_params(host): + # lift recognized connection params (verify_ssl) off one host url; + # return (cleaned_host, params). non-http urls pass through untouched. + parsed = urlparse(host) + if parsed.scheme not in ('http', 'https') or not parsed.query: + return host, {} + + lifted = {} + kept = [] + for key, value in parse_qsl(parsed.query, keep_blank_values=True): + if key == 'verify_ssl': + lifted[key] = boolean(value) + else: + kept.append((key, value)) + return urlunparse(parsed._replace(query=urlencode(kept))), lifted + + +def collect_url_params(hosts): + # fold extract_url_params over a host list; first occurrence wins. + cleaned = [] + params = {} + for host in hosts: + c, lifted = extract_url_params(host) + cleaned.append(c) + for key, value in lifted.items(): + params.setdefault(key, value) + return cleaned, params + + def get_information_schema_query(lowest_server_version): schema_name = \ "table_schema" if lowest_server_version >= \ @@ -614,7 +646,16 @@ def main(): printer.info(crash_version) sys.exit(0) - crate_hosts = [host_and_port(h) for h in args.hosts] + try: + cleaned_hosts, url_params = collect_url_params(args.hosts) + except ArgumentTypeError as e: + printer.warn(str(e)) + sys.exit(1) + crate_hosts = [host_and_port(h) for h in cleaned_hosts] + + if args.verify_ssl is None: + args.verify_ssl = url_params.get('verify_ssl', True) + error_trace = args.verbose > 0 password = _resolve_password(is_tty, args.force_passwd_prompt) diff --git a/tests/test_command.py b/tests/test_command.py index 6af8fb4d..ce24c157 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # vim: set fileencodings=utf-8 +from argparse import ArgumentTypeError from unittest import TestCase from verlib2 import Version @@ -9,7 +10,10 @@ Result, _decode_timeout, _decode_timeouts, + collect_url_params, + extract_url_params, get_information_schema_query, + get_parser, host_and_port, stmt_type, ) @@ -103,6 +107,52 @@ def test_short_hostnames(self): self.assertEqual(host_and_port(':'), 'localhost:4200') +class UrlParamsTest(TestCase): + + def test_extract_url_params(self): + for value, expected in [ + ('false', False), ('False', False), ('0', False), ('no', False), + ('true', True), ('1', True), ('yes', True), + ]: + cleaned, params = extract_url_params( + f'https://u:p@h:4200/?foo=bar&verify_ssl={value}') + self.assertEqual(cleaned, 'https://u:p@h:4200/?foo=bar') + self.assertEqual(params, {'verify_ssl': expected}) + + for host in ('localhost:4200', ':4200', ':', 'localhost', + 'https://h:4200/', 'postgresql://h/?verify_ssl=false', + 'localhost:4200?foo=bar'): + self.assertEqual(extract_url_params(host), (host, {})) + + with self.assertRaises(ArgumentTypeError): + extract_url_params('https://h/?verify_ssl=maybe') + + def test_first_host_wins(self): + _, params = collect_url_params([ + 'https://a/?verify_ssl=false', + 'https://b/?verify_ssl=true', + ]) + self.assertIs(params['verify_ssl'], False) + + def test_verify_ssl_precedence(self): + # --verify-ssl > url > default True. + def resolve(argv): + args = get_parser().parse_args(argv) + _, url_params = collect_url_params(args.hosts) + return url_params.get('verify_ssl', True) \ + if args.verify_ssl is None else args.verify_ssl + + self.assertIs(resolve(['--hosts', 'h:4200']), True) + self.assertIs( + resolve(['--hosts', 'https://h/?verify_ssl=false']), False) + self.assertIs( + resolve(['--hosts', 'https://h/?verify_ssl=false', + '--verify-ssl', 'true']), True) + self.assertIs( + resolve(['--hosts', 'https://h/?verify_ssl=true', + '--verify-ssl', 'false']), False) + + class CommandUtilsTest(TestCase): def test_stmt_type(self):