Skip to content

Commit 5de25a6

Browse files
authored
HTTPS/SSL Support and Password Obfuscation Control (#13)
1 parent fc7884d commit 5de25a6

3 files changed

Lines changed: 394 additions & 8 deletions

File tree

README.md

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ Async Python client for [NanoKVM](https://github.com/sipeed/NanoKVM).
88
from nanokvm.client import NanoKVMClient
99
from nanokvm.models import GpioType, MouseButton
1010

11-
async with NanoKVMClient("http://kvm-8b76.local/api/") as client:
11+
# NanoKVM (auto-detects password mode)
12+
async with NanoKVMClient("https://kvm.local/api/") as client:
1213
await client.authenticate("username", "password")
1314

1415
# Get device information
@@ -50,3 +51,66 @@ disk = await ssh.run_command("df -h /")
5051

5152
await ssh.disconnect()
5253
```
54+
55+
### Password Obfuscation Modes
56+
57+
By default, the client **auto-detects** the correct password mode. It tries obfuscated password first, and falls back to plain text if authentication fails. You can also force a specific mode:
58+
59+
```python
60+
# Auto-detect (default) — recommended
61+
async with NanoKVMClient("https://kvm.local/api/") as client:
62+
await client.authenticate("username", "password")
63+
64+
# Force plain text (newer NanoKVM with HTTPS)
65+
async with NanoKVMClient(
66+
"https://kvm.local/api/",
67+
use_password_obfuscation=False
68+
) as client:
69+
await client.authenticate("username", "password")
70+
71+
# Force obfuscation (older NanoKVM with HTTP)
72+
async with NanoKVMClient(
73+
"http://kvm.local/api/",
74+
use_password_obfuscation=True
75+
) as client:
76+
await client.authenticate("username", "password")
77+
```
78+
79+
## HTTPS/SSL Configuration
80+
81+
The client supports HTTPS connections with flexible SSL/TLS configuration options.
82+
83+
### Standard HTTPS (Let's Encrypt, Public CA)
84+
85+
For modern NanoKVM devices with HTTPS and valid certificates:
86+
87+
```python
88+
async with NanoKVMClient("https://kvm.local/api/") as client:
89+
await client.authenticate("username", "password")
90+
```
91+
92+
### Self-Signed Certificates
93+
94+
For self-signed certificates, you have two options:
95+
96+
#### Option 1: Disable verification (testing only)
97+
98+
**Warning:** This is insecure and should only be used for testing!
99+
100+
```python
101+
async with NanoKVMClient(
102+
"https://kvm.local/api/",
103+
verify_ssl=False,
104+
) as client:
105+
await client.authenticate("username", "password")
106+
```
107+
108+
#### Option 2: Use custom CA certificate (recommended)
109+
110+
```python
111+
async with NanoKVMClient(
112+
"https://kvm.local/api/",
113+
ssl_ca_cert="/path/to/ca.pem",
114+
) as client:
115+
await client.authenticate("username", "password")
116+
```

nanokvm/client.py

Lines changed: 89 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,18 @@
88
import io
99
import json
1010
import logging
11+
import ssl
1112
from typing import Any, TypeVar, overload
1213

1314
import aiohttp
14-
from aiohttp import BodyPartReader, ClientResponse, ClientSession, MultipartReader, hdrs
15+
from aiohttp import (
16+
BodyPartReader,
17+
ClientResponse,
18+
ClientSession,
19+
MultipartReader,
20+
TCPConnector,
21+
hdrs,
22+
)
1523
from PIL import Image
1624
from pydantic import BaseModel, ValidationError
1725
import yarl
@@ -115,20 +123,63 @@ def __init__(
115123
*,
116124
token: str | None = None,
117125
request_timeout: int = 10,
126+
verify_ssl: bool = True,
127+
ssl_ca_cert: str | None = None,
128+
use_password_obfuscation: bool | None = None,
118129
) -> None:
119130
"""
120131
Initialize the NanoKVM client.
121132
122133
Args:
123-
url: Base URL of the NanoKVM API (e.g., "http://192.168.1.1/api/")
134+
url: Base URL of the NanoKVM API (e.g., "https://kvm.local/api/")
124135
token: Optional pre-existing authentication token
125136
request_timeout: Request timeout in seconds (default: 10)
137+
verify_ssl: Enable SSL certificate verification (default: True).
138+
Set to False to disable verification for self-signed certificates.
139+
ssl_ca_cert: Path to custom CA certificate bundle file for SSL verification.
140+
Useful for self-signed certificates or private CAs.
141+
use_password_obfuscation: Control password obfuscation mode (default: None).
142+
None = auto-detect (try obfuscated first, fall back to plain text).
143+
True = always use obfuscated passwords (older NanoKVM versions).
144+
False = always use plain text passwords (newer HTTPS-enabled versions).
126145
"""
127146
self.url = yarl.URL(url)
128147
self._session: ClientSession | None = None
129148
self._token = token
130149
self._request_timeout = request_timeout
131150
self._ws: aiohttp.ClientWebSocketResponse | None = None
151+
self._verify_ssl = verify_ssl
152+
self._ssl_ca_cert = ssl_ca_cert
153+
self._use_password_obfuscation = use_password_obfuscation
154+
155+
def _create_ssl_context(self) -> ssl.SSLContext | bool:
156+
"""
157+
Create and configure SSL context based on initialization parameters.
158+
159+
Returns:
160+
ssl.SSLContext: Configured SSL context for custom certificates
161+
True: Use default SSL verification (aiohttp default)
162+
False: Disable SSL verification
163+
164+
Raises:
165+
FileNotFoundError: If the CA certificate file is missing.
166+
ssl.SSLError: If the CA certificate is invalid.
167+
"""
168+
169+
if not self._verify_ssl:
170+
_LOGGER.warning(
171+
"SSL verification is disabled. This is insecure and should only be "
172+
"used for testing with self-signed certificates."
173+
)
174+
return False
175+
176+
if not self._ssl_ca_cert:
177+
return True
178+
179+
ssl_ctx = ssl.create_default_context(cafile=self._ssl_ca_cert)
180+
_LOGGER.debug("Using custom CA certificate: %s", self._ssl_ca_cert)
181+
182+
return ssl_ctx
132183

133184
@property
134185
def token(self) -> str | None:
@@ -137,7 +188,16 @@ def token(self) -> str | None:
137188

138189
async def __aenter__(self) -> NanoKVMClient:
139190
"""Async context manager entry."""
140-
self._session = ClientSession()
191+
192+
ssl_config = await asyncio.to_thread(self._create_ssl_context)
193+
connector = TCPConnector(ssl=ssl_config)
194+
self._session = ClientSession(connector=connector)
195+
196+
_LOGGER.debug(
197+
"Created client session with SSL verification: %s",
198+
"disabled" if ssl_config is False else "enabled",
199+
)
200+
141201
return self
142202

143203
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
@@ -243,9 +303,8 @@ async def _api_request_json(
243303

244304
return api_response.data
245305

246-
async def authenticate(self, username: str, password: str) -> None:
247-
"""Authenticate and store the session token."""
248-
_LOGGER.debug("Attempting authentication for user: %s", username)
306+
async def _do_authenticate(self, username: str, password_to_send: str) -> None:
307+
"""Perform a single authentication attempt with the given password."""
249308
try:
250309
login_response = await self._api_request_json(
251310
hdrs.METH_POST,
@@ -254,7 +313,7 @@ async def authenticate(self, username: str, password: str) -> None:
254313
authenticate=False,
255314
data=LoginReq(
256315
username=username,
257-
password=obfuscate_password(password),
316+
password=password_to_send,
258317
),
259318
)
260319

@@ -272,6 +331,29 @@ async def authenticate(self, username: str, password: str) -> None:
272331
else:
273332
raise
274333

334+
async def authenticate(self, username: str, password: str) -> None:
335+
"""Authenticate and store the session token."""
336+
_LOGGER.debug("Attempting authentication for user: %s", username)
337+
338+
if self._use_password_obfuscation is True:
339+
_LOGGER.debug("Using password obfuscation (forced)")
340+
await self._do_authenticate(username, obfuscate_password(password))
341+
elif self._use_password_obfuscation is False:
342+
_LOGGER.debug("Using plain text password (forced)")
343+
await self._do_authenticate(username, password)
344+
else:
345+
# Auto-detect: try obfuscated first, fall back to plain text
346+
_LOGGER.debug("Auto-detecting password mode")
347+
try:
348+
await self._do_authenticate(username, obfuscate_password(password))
349+
_LOGGER.info("Auto-detected obfuscated password mode")
350+
except NanoKVMAuthenticationFailure:
351+
_LOGGER.debug(
352+
"Obfuscated authentication failed, trying plain text password"
353+
)
354+
await self._do_authenticate(username, password)
355+
_LOGGER.info("Auto-detected plain text password mode")
356+
275357
async def logout(self) -> None:
276358
"""Log out and clear the session token."""
277359
if not self._token or self._token == "disabled":

0 commit comments

Comments
 (0)