Skip to content

Commit 4a67ba6

Browse files
authored
feat: add support for SSL certificate verification in CLI and connection (#47)
* feat: add support for SSL certificate verification in CLI and connection * fix: ensure client is closed after connection attempt in CLI
1 parent dbb6713 commit 4a67ba6

10 files changed

Lines changed: 214 additions & 14 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@ qql connect --url http://localhost:6333
8080

8181
# Qdrant Cloud
8282
qql connect --url https://<your-cluster>.qdrant.io --secret <your-api-key>
83+
84+
# Internal/self-signed certificate
85+
qql connect --url https://<your-host>:6333 --secret <your-api-key> --ca-cert /path/to/ca.pem
86+
87+
# Disable TLS verification when you cannot provide a CA bundle
88+
qql connect --url https://<your-host>:6333 --secret <your-api-key> --no-verify
8389
```
8490

8591
Then type `qql` to open the interactive shell.

docs/getting-started.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,24 @@ qql connect --url http://localhost:6333
7777
qql connect --url https://<your-cluster>.qdrant.io --secret <your-api-key>
7878
```
7979

80+
### HTTPS with an internal CA or self-signed certificate
81+
82+
Prefer a custom CA bundle when your Qdrant endpoint uses an internal or
83+
self-signed certificate:
84+
85+
```bash
86+
qql connect --url https://<your-host>:6333 --secret <your-api-key> --ca-cert /path/to/ca.pem
87+
```
88+
89+
If you cannot provide a CA bundle, TLS verification can be disabled:
90+
91+
```bash
92+
qql connect --url https://<your-host>:6333 --secret <your-api-key> --no-verify
93+
```
94+
95+
`--no-verify` should be limited to trusted internal environments because it
96+
skips certificate validation.
97+
8098
On success you will see:
8199

82100
```

docs/programmatic.md

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,30 @@ with Connection("https://<your-cluster>.qdrant.io", secret="<your-api-key>") as
7070
print(result.data)
7171
```
7272

73+
### Internal or self-signed certificates
74+
75+
Prefer a custom CA bundle when your Qdrant endpoint uses an internal or
76+
self-signed certificate:
77+
78+
```python
79+
from qql import Connection
80+
81+
with Connection(
82+
"https://<your-host>:6333",
83+
secret="<your-api-key>",
84+
verify="/path/to/ca.pem",
85+
) as conn:
86+
result = conn.run_query("SHOW COLLECTIONS")
87+
```
88+
89+
If a CA bundle is not available, pass `verify=False` to disable TLS
90+
verification for trusted internal environments:
91+
92+
```python
93+
with Connection("https://<your-host>:6333", verify=False) as conn:
94+
...
95+
```
96+
7397
### Custom embedding model
7498

7599
```python
@@ -155,6 +179,7 @@ with Connection("http://localhost:6333") as conn:
155179
| `url` | `str` | `"http://localhost:6333"` | Qdrant instance URL |
156180
| `secret` | `str \| None` | `None` | API key; `None` for unauthenticated |
157181
| `default_model` | `str \| None` | `None``sentence-transformers/all-MiniLM-L6-v2` | Dense embedding model used when no `USING MODEL` clause is given |
182+
| `verify` | `bool \| str` | `True` | TLS verification setting; use `False` to skip verification or a CA bundle path for internal/self-signed certificates |
158183
| `default_dense_vector_name` | `str` | `"dense"` | Dense vector name used when QQL creates a collection and no explicit `USING VECTOR` name is given |
159184
| `default_sparse_vector_name` | `str` | `"sparse"` | Sparse vector name used when QQL creates a hybrid collection and no explicit sparse vector name is given |
160185

@@ -200,8 +225,8 @@ for hit in result.data:
200225
print(hit["score"], hit["payload"])
201226
```
202227

203-
`run_query()` accepts the same `url`, `secret`, and `default_model` parameters
204-
as `Connection.__init__()`.
228+
`run_query()` accepts the same `url`, `secret`, `default_model`, and `verify`
229+
parameters as `Connection.__init__()`.
205230

206231
---
207232

docs/reference.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,8 @@ The connection config is stored at `~/.qql/config.json`:
133133
{
134134
"url": "http://localhost:6333",
135135
"secret": null,
136-
"default_model": "sentence-transformers/all-MiniLM-L6-v2"
136+
"default_model": "sentence-transformers/all-MiniLM-L6-v2",
137+
"verify": true
137138
}
138139
```
139140

@@ -142,6 +143,7 @@ The connection config is stored at `~/.qql/config.json`:
142143
| `url` | Qdrant instance URL |
143144
| `secret` | API key (null if not required) |
144145
| `default_model` | Dense embedding model used when no `USING MODEL` clause is given |
146+
| `verify` | TLS verification setting: `true`, `false`, or a custom CA bundle path from `--ca-cert` |
145147

146148
You can edit this file directly to change the default model without reconnecting.
147149

src/qql/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ def run_query(
4242
url: str = "http://localhost:6333",
4343
secret: str | None = None,
4444
default_model: str | None = None,
45+
verify: bool | str = True,
4546
) -> ExecutionResult:
4647
"""One-shot convenience function kept for backward compatibility.
4748
@@ -55,5 +56,10 @@ def run_query(
5556
with Connection(url, secret=secret) as conn:
5657
result = conn.run_query(query)
5758
"""
58-
with Connection(url=url, secret=secret, default_model=default_model) as conn:
59+
with Connection(
60+
url=url,
61+
secret=secret,
62+
default_model=default_model,
63+
verify=verify,
64+
) as conn:
5965
return conn.run_query(query)

src/qql/cli.py

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -173,20 +173,44 @@ def main(ctx: click.Context) -> None:
173173
@main.command()
174174
@click.option("--url", required=True, help="Qdrant instance URL, e.g. http://localhost:6333")
175175
@click.option("--secret", default=None, help="API key / secret (optional)")
176-
def connect(url: str, secret: str | None) -> None:
176+
@click.option(
177+
"--verify/--no-verify",
178+
default=True,
179+
show_default=True,
180+
help="Verify SSL/TLS certificate (disable for self-signed certs).",
181+
)
182+
@click.option(
183+
"--ca-cert",
184+
default=None,
185+
type=click.Path(exists=True, readable=True, dir_okay=False, resolve_path=True),
186+
help="Path to a custom CA certificate bundle (PEM).",
187+
)
188+
def connect(
189+
url: str,
190+
secret: str | None,
191+
verify: bool,
192+
ca_cert: str | None,
193+
) -> None:
177194
"""Connect to a Qdrant instance and launch the QQL shell."""
178195
from qdrant_client import QdrantClient
179196

197+
if ca_cert and not verify:
198+
raise click.UsageError("--ca-cert cannot be used with --no-verify.")
199+
200+
verify_val: bool | str = ca_cert if ca_cert else verify
201+
180202
console.print(f"Connecting to [bold]{url}[/bold]...")
181203

182204
try:
183-
client = QdrantClient(url=url, api_key=secret)
184-
client.get_collections() # validate connection
205+
client = QdrantClient(url=url, api_key=secret, verify=verify_val)
206+
client.get_collections()
185207
except Exception as e:
186208
err_console.print(f"[bold red]Connection failed:[/bold red] {e}")
187209
sys.exit(1)
210+
else:
211+
client.close()
188212

189-
cfg = QQLConfig(url=url, secret=secret)
213+
cfg = QQLConfig(url=url, secret=secret, verify=verify_val)
190214
save_config(cfg)
191215
console.print("[bold green]Connected.[/bold green] Config saved to ~/.qql/config.json\n")
192216
_launch_repl(cfg)
@@ -228,7 +252,7 @@ def execute(file: str, stop_on_error: bool) -> None:
228252
sys.exit(1)
229253

230254
try:
231-
client = QdrantClient(url=cfg.url, api_key=cfg.secret)
255+
client = QdrantClient(url=cfg.url, api_key=cfg.secret, verify=cfg.verify)
232256
client.get_collections()
233257
except Exception as e:
234258
err_console.print(f"[bold red]Connection failed:[/bold red] {e}")
@@ -286,7 +310,7 @@ def dump(collection: str, output: str, batch_size: int) -> None:
286310
sys.exit(1)
287311

288312
try:
289-
client = QdrantClient(url=cfg.url, api_key=cfg.secret)
313+
client = QdrantClient(url=cfg.url, api_key=cfg.secret, verify=cfg.verify)
290314
client.get_collections()
291315
except Exception as e:
292316
err_console.print(f"[bold red]Connection failed:[/bold red] {e}")
@@ -319,7 +343,7 @@ def _launch_repl(cfg: QQLConfig) -> None:
319343
from qdrant_client import QdrantClient
320344

321345
try:
322-
client = QdrantClient(url=cfg.url, api_key=cfg.secret)
346+
client = QdrantClient(url=cfg.url, api_key=cfg.secret, verify=cfg.verify)
323347
client.get_collections()
324348
except Exception as e:
325349
err_console.print(f"[bold red]Could not connect to {cfg.url}:[/bold red] {e}")

src/qql/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ class QQLConfig:
1919
default_model: str = DEFAULT_MODEL
2020
default_dense_vector_name: str = DEFAULT_DENSE_VECTOR_NAME
2121
default_sparse_vector_name: str = DEFAULT_SPARSE_VECTOR_NAME
22+
verify: bool | str = True
2223

2324

2425
def save_config(cfg: QQLConfig) -> None:
@@ -43,6 +44,7 @@ def load_config() -> QQLConfig | None:
4344
default_sparse_vector_name=data.get(
4445
"default_sparse_vector_name", DEFAULT_SPARSE_VECTOR_NAME
4546
),
47+
verify=data.get("verify", True),
4648
)
4749

4850

src/qql/connection.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ def __init__(
5151
url: str = "http://localhost:6333",
5252
secret: str | None = None,
5353
default_model: str | None = None,
54+
verify: bool | str = True,
5455
) -> None:
5556
"""Create a connection to a Qdrant instance.
5657
@@ -60,15 +61,19 @@ def __init__(
6061
default_model: Dense embedding model used when no ``USING MODEL`` clause
6162
is specified. Defaults to
6263
``sentence-transformers/all-MiniLM-L6-v2``.
64+
verify: SSL certificate verification. Set to ``False`` to skip
65+
verification for self-signed/internal certificates, or pass
66+
a path to a custom CA bundle (default: ``True``).
6367
"""
6468
from qdrant_client import QdrantClient
6569

6670
self._config = QQLConfig(
6771
url=url,
6872
secret=secret,
6973
default_model=default_model or DEFAULT_MODEL,
74+
verify=verify,
7075
)
71-
self._client = QdrantClient(url=url, api_key=secret)
76+
self._client = QdrantClient(url=url, api_key=secret, verify=verify)
7277
self._executor = Executor(self._client, self._config)
7378

7479
# ── Public API ────────────────────────────────────────────────────────

tests/test_cli.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""Tests for the Click CLI surface."""
2+
3+
from click.testing import CliRunner
4+
5+
from qql import QQLConfig
6+
from qql.cli import main
7+
8+
9+
def test_connect_saves_no_verify_config(mocker):
10+
mock_client = mocker.MagicMock()
11+
mock_client_cls = mocker.patch("qdrant_client.QdrantClient", return_value=mock_client)
12+
save_config = mocker.patch("qql.cli.save_config")
13+
launch_repl = mocker.patch("qql.cli._launch_repl")
14+
15+
result = CliRunner().invoke(
16+
main,
17+
["connect", "--url", "https://internal.example.io", "--no-verify"],
18+
)
19+
20+
assert result.exit_code == 0
21+
mock_client_cls.assert_called_once_with(
22+
url="https://internal.example.io", api_key=None, verify=False
23+
)
24+
save_config.assert_called_once_with(
25+
QQLConfig(url="https://internal.example.io", secret=None, verify=False)
26+
)
27+
launch_repl.assert_called_once()
28+
29+
30+
def test_connect_saves_custom_ca_bundle_config(tmp_path, mocker):
31+
ca_cert = tmp_path / "internal-ca.pem"
32+
ca_cert.write_text("certificate")
33+
mock_client = mocker.MagicMock()
34+
mock_client_cls = mocker.patch("qdrant_client.QdrantClient", return_value=mock_client)
35+
save_config = mocker.patch("qql.cli.save_config")
36+
mocker.patch("qql.cli._launch_repl")
37+
38+
result = CliRunner().invoke(
39+
main,
40+
[
41+
"connect",
42+
"--url",
43+
"https://internal.example.io",
44+
"--ca-cert",
45+
str(ca_cert),
46+
],
47+
)
48+
49+
assert result.exit_code == 0
50+
verify = str(ca_cert.resolve())
51+
mock_client_cls.assert_called_once_with(
52+
url="https://internal.example.io", api_key=None, verify=verify
53+
)
54+
save_config.assert_called_once_with(
55+
QQLConfig(url="https://internal.example.io", secret=None, verify=verify)
56+
)
57+
58+
59+
def test_connect_rejects_ca_bundle_when_verification_is_disabled(tmp_path):
60+
ca_cert = tmp_path / "internal-ca.pem"
61+
ca_cert.write_text("certificate")
62+
63+
result = CliRunner().invoke(
64+
main,
65+
[
66+
"connect",
67+
"--url",
68+
"https://internal.example.io",
69+
"--no-verify",
70+
"--ca-cert",
71+
str(ca_cert),
72+
],
73+
)
74+
75+
assert result.exit_code != 0
76+
assert "--ca-cert cannot be used with --no-verify" in result.output
77+
78+
79+
def test_execute_uses_saved_verify_config(tmp_path, mocker):
80+
script = tmp_path / "script.qql"
81+
script.write_text("SHOW COLLECTIONS")
82+
cfg = QQLConfig(url="https://internal.example.io", secret="s3cr3t", verify=False)
83+
mock_client = mocker.MagicMock()
84+
mock_client_cls = mocker.patch("qdrant_client.QdrantClient", return_value=mock_client)
85+
mocker.patch("qql.cli.load_config", return_value=cfg)
86+
run_script = mocker.patch("qql.script.run_script", return_value=(1, 0))
87+
88+
result = CliRunner().invoke(main, ["execute", str(script)])
89+
90+
assert result.exit_code == 0
91+
mock_client_cls.assert_called_once_with(
92+
url="https://internal.example.io", api_key="s3cr3t", verify=False
93+
)
94+
run_script.assert_called_once()

tests/test_connection.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,27 @@ def test_custom_url_and_secret_passed_to_qdrant_client(self, mocker):
2323
mock_client_cls = mocker.patch("qdrant_client.QdrantClient")
2424
Connection("https://cloud.example.io", secret="s3cr3t")
2525
mock_client_cls.assert_called_once_with(
26-
url="https://cloud.example.io", api_key="s3cr3t"
26+
url="https://cloud.example.io", api_key="s3cr3t", verify=True
2727
)
2828

29+
def test_ssl_verify_option_passed_to_qdrant_client(self, mocker):
30+
mock_client_cls = mocker.patch("qdrant_client.QdrantClient")
31+
conn = Connection("https://internal.example.io", verify=False)
32+
mock_client_cls.assert_called_once_with(
33+
url="https://internal.example.io", api_key=None, verify=False
34+
)
35+
assert conn.config.verify is False
36+
37+
def test_custom_ca_bundle_passed_to_qdrant_client(self, mocker):
38+
mock_client_cls = mocker.patch("qdrant_client.QdrantClient")
39+
conn = Connection("https://internal.example.io", verify="/etc/ssl/internal-ca.pem")
40+
mock_client_cls.assert_called_once_with(
41+
url="https://internal.example.io",
42+
api_key=None,
43+
verify="/etc/ssl/internal-ca.pem",
44+
)
45+
assert conn.config.verify == "/etc/ssl/internal-ca.pem"
46+
2947
def test_custom_default_model_stored_in_config(self, mocker):
3048
mocker.patch("qdrant_client.QdrantClient")
3149
conn = Connection("http://localhost:6333", default_model="BAAI/bge-small-en-v1.5")
@@ -156,7 +174,7 @@ def test_run_query_delegates_to_connection(self, mocker):
156174
conn_cls = mocker.patch("qql.Connection", return_value=conn_instance)
157175
run_query("SHOW COLLECTIONS", url="http://localhost:6333")
158176
conn_cls.assert_called_once_with(
159-
url="http://localhost:6333", secret=None, default_model=None
177+
url="http://localhost:6333", secret=None, default_model=None, verify=True
160178
)
161179
conn_instance.run_query.assert_called_once_with("SHOW COLLECTIONS")
162180

0 commit comments

Comments
 (0)