Daytona 自托管 + 自签名证书 + 连接使用:一次完整的 SSL 排查实录
Daytona 自托管 + 自签名证书:一次完整的 SSL 排查实录
自托管 Daytona 时使用自签名证书,Python SDK 的 SSL 验证问题比你想象的要复杂得多。这篇文章记录了从第一个报错到最终解决的完整过程,以及每一步为什么失败、为什么最终能成功。
背景
Daytona 是一个为 AI Agent 设计的沙盒运行时基础设施,支持通过 Python SDK 在隔离环境中执行代码。自托管时如果使用自签名证书(如内网 IP 直接部署),SDK 的 SSL 验证会持续报错。
环境:
- Daytona 自托管,地址
https://<your-server-ip>:<port> - 自签名证书(非 CA 签发)
- Python SDK:
daytona/daytona_sdk
第一个报错:sandbox 创建失败
Failed to create sandbox: HTTPSConnectionPool(host='<your-server-ip>', port=<port>):
Max retries exceeded with url: /api/sandbox
(Caused by SSLError(SSLCertVerificationError(1,
'[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self-signed certificate (_ssl.c:1010)')))
第一次尝试:事后修改 configuration
自然的第一反应——创建 Daytona 实例后,直接修改内部的 configuration:
daytona_client = Daytona(DaytonaConfig(...))
daytona_client._api_client.configuration.ssl_ca_cert = str(cert)
为什么失败? 看 RESTClientObject.__init__ 的源码:
class RESTClientObject:
def __init__(self, configuration) -> None:
if configuration.verify_ssl:
cert_reqs = ssl.CERT_REQUIRED
else:
cert_reqs = ssl.CERT_NONE
pool_args = {
"cert_reqs": cert_reqs,
"ca_certs": configuration.ssl_ca_cert,
# ...
}
self.pool_manager = urllib3.PoolManager(**pool_args)
cert_reqs 和 ca_certs 在 __init__ 时就固化进了 urllib3.PoolManager。之后再改 configuration 的任何属性,连接池不会感知,已有连接照旧复用旧的 SSL 配置。
第二个报错:文件上传失败
用 patch Configuration.__init__ 的方式解决了 sandbox 创建,但立刻出现了新问题:
Failed to upload files: [SSL: CERTIFICATE_VERIFY_FAILED]
certificate verify failed: self-signed certificate (_ssl.c:1010)
sandbox 创建成功了,命令执行也成功了,唯独文件上传失败。
原因:Daytona 有两个 API Client
看 Daytona.__init__ 的源码:
self._api_client: ApiClient = ApiClient(configuration) # 主 API
self._toolbox_api_client: ToolboxApiClient = self._clone_api_client_to_toolbox_api_client() # toolbox
toolbox 是单独 clone 出来的,文件上传、文件读写等操作走的是 toolbox client,不是主 API client。
def _clone_api_client_to_toolbox_api_client(self) -> ToolboxApiClient:
config = deepcopy(self._api_client.configuration)
config.host = "" # host 被清空,运行时动态注入
toolbox_api_client = ToolboxApiClient(config)
return toolbox_api_client
所以需要同时 patch 两个地方。
第二次尝试:patch ToolboxConfiguration
from daytona_toolbox_api_client import Configuration as ToolboxConfiguration
def _toolbox_patched(self, *args, **kwargs):
_toolbox_orig(self, *args, **kwargs)
self.ssl_ca_cert = str(cert)
ToolboxConfiguration.__init__ = _toolbox_patched
依然失败。 诊断发现:
inner client configuration type: <class 'daytona_api_client.configuration.Configuration'>
toolbox client 用的 Configuration 根本不是 daytona_toolbox_api_client.Configuration,而是 daytona_api_client.Configuration(因为 deepcopy 的是主 client 的 configuration)。patch 的根本就不是同一个类。
深入排查:toolbox 请求走哪里
进一步看 Sandbox.__init__:
self._toolbox_api = ToolboxApiClientProxy(
toolbox_api, self.id, self.toolbox_proxy_url # ← 来自 API response
)
ToolboxApiClientProxy 在每次请求时动态覆盖 host:
def param_serialize(self, *args, **kwargs):
kwargs["_host"] = self._toolbox_base_url # 动态注入 host
return self._api_client.param_serialize(*args, **kwargs)
打印出来:
toolbox_proxy_url: https://<your-server-ip>:<port>/toolbox
同一台机器,同一张证书,但每次请求动态换 host 会让 urllib3 新建连接池——新连接池的 SSL 配置从 configuration 读,但如果 RESTClientObject 已经建好了,改 configuration 也没用。
关键发现:还有 httpx
verify_ssl = False 的 patch 加上了,configuration.verify_ssl 打印出来也是 False,但还是报错。
这说明某些请求根本不走 urllib3,而是走 httpx。
Daytona SDK 同时使用了两个 HTTP 库:
- urllib3(通过 openapi-generator 生成的
ApiClient)→ 处理主 API 和 toolbox 的大部分请求 - httpx → 处理部分操作(文件上传等流式请求)
这就是为什么 patch Configuration 始终无法完全解决问题的根本原因。
最终解决方案
需要同时 patch 两个 HTTP 库:
import httpx
from pathlib import Path
from daytona_api_client import Configuration
from daytona import Daytona, DaytonaConfig
cert = Path(__file__).resolve().parents[2] / "cert.pem"
# 1. Patch httpx.Client — 处理文件上传等操作
_orig_httpx_init = httpx.Client.__init__
def patched_httpx_init(self, *args, **kwargs):
kwargs.setdefault("verify", str(cert)) # 只在未指定时注入,不覆盖显式配置
return _orig_httpx_init(self, *args, **kwargs)
httpx.Client.__init__ = patched_httpx_init
# 2. Patch urllib3 的 Configuration — 处理主 API 和 toolbox 的常规请求
_orig_config_init = Configuration.__init__
def patched_config_init(self, *args, **kwargs):
_orig_config_init(self, *args, **kwargs)
self.ssl_ca_cert = str(cert) # 证书路径注入连接池参数
Configuration.__init__ = patched_config_init
# 3. 构造 Daytona 客户端(两个 patch 必须在这之前生效)
daytona_client = Daytona(DaytonaConfig())
# 4. 可选:还原 patch,避免影响后续其他代码
httpx.Client.__init__ = _orig_httpx_init
Configuration.__init__ = _orig_config_init
为什么这样可以
| 请求类型 | HTTP 库 | 修复方式 |
|---|---|---|
| sandbox 创建、命令执行 | urllib3 | Configuration.__init__ patch |
| toolbox 常规操作 | urllib3(deepcopy) | 同上,因为 deepcopy 继承了 patch 后的 configuration |
文件上传(fs.upload_file) | httpx | httpx.Client.__init__ patch |
关键细节:
kwargs.setdefault("verify", str(cert)) 用 setdefault 而非直接赋值,只在调用方没有显式传 verify 时才注入。这样不会影响其他代码里有意设置 verify=True 或 verify=False 的 httpx 请求,做到了最小侵入。
证书文件如何获取
如果还没有 cert.pem,从服务器直接导出:
openssl s_client -connect <your-server-ip>:<port> -showcerts </dev/null 2>/dev/null \
| openssl x509 -outform PEM > cert.pem
# 验证自签名证书(用自身作为 CA 验证)
openssl verify -CAfile cert.pem cert.pem
# 输出 cert.pem: OK 则正确
踩坑总结
- 事后改
configuration无效:urllib3 的PoolManager在RESTClientObject.__init__时就把 SSL 参数固化了,之后改configuration属性不会影响已有连接池。 - patch 要在构造前:
Daytona()构造时会立即建立连接池,必须在此之前完成 patch。 - toolbox 用的不是
daytona_toolbox_api_client.Configuration:它是从主 client deepcopy 来的,patch 错了类完全没用。 - SDK 混用了 urllib3 和 httpx:只 patch 一个不够,文件上传走 httpx,必须两个都 patch。
- 自签名证书 ≠ 私有 CA:
ssl_ca_cert期望的是 CA 证书,自签名证书需要把证书本身当 CA 来信任,导出方式见上。
长期建议
这套 patch 方案能用,但毕竟是 monkey patch,依赖内部实现,SDK 大版本升级可能失效。更稳妥的长期方案:
- 用 mkcert 生成本地受信任证书:
mkcert <your-server-ip>,系统级信任,SDK 无需任何额外配置。 - 向 Daytona 提 issue:在
DaytonaConfig加ssl_ca_cert和verify_ssl字段,从根本上支持自定义证书,改动极小。 - 内网部署用 Caddy + Let’s Encrypt DNS-01:即使是内网 IP,也可以通过域名 + DNS 验证获取公网受信任证书。
更多推荐



所有评论(0)