From d70a42b349743c30947a4630e9fa830733fa7195 Mon Sep 17 00:00:00 2001 From: sascha Date: Wed, 22 Jul 2026 11:09:38 +0200 Subject: [PATCH 1/4] sync: README.md for Butler 2.3.1 --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 95c8d18..a2587ce 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Unified API proxy and infrastructure management for Homelab Pfannkuchen. - **Base URL:** `http://10.4.1.116:8888` - **Authentication:** `Authorization: Bearer ` -- **Version:** 2.3.0 +- **Version:** 2.3.1 - **Interactive API documentation:** `/docs` ## Service proxy @@ -97,6 +97,11 @@ Integration Compose definition: `tests/compose.integration.yaml` (binds only to ## Changelog +### 2.3.1 — 22.07.2026 + +- Use a writable runtime `known_hosts` file for SSH probes with read-only SSH mounts. +- Correct the Semaphore backend port from the repurposed MCP-Dockhand port to port 3010. + ### 2.3.0 — 22.07.2026 - Added `/overview`, a compact schema-versioned verdict for lightweight language models. -- 2.49.1 From 87569b55d57e773c2d132a990083933b1eedb09c Mon Sep 17 00:00:00 2001 From: sascha Date: Wed, 22 Jul 2026 11:09:39 +0200 Subject: [PATCH 2/4] sync: app.py for Butler 2.3.1 --- app.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app.py b/app.py index f818509..ddce37b 100644 --- a/app.py +++ b/app.py @@ -11,7 +11,7 @@ from fastapi.responses import JSONResponse, RedirectResponse from contextlib import asynccontextmanager log = logging.getLogger("butler") -VERSION = "2.3.0" +VERSION = "2.3.1" API_DIR = os.environ.get("API_KEY_DIR", "/data/api") VAULT_CACHE_DIR = os.environ.get("VAULT_CACHE_DIR", "/data/vault-cache") @@ -788,7 +788,8 @@ class VMCreate(BaseModel): def _ssh(host, cmd, timeout=600): try: - r = _sp.run(["ssh","-o","ConnectTimeout=10","-o","StrictHostKeyChecking=accept-new",host,cmd], + r = _sp.run(["ssh","-o","ConnectTimeout=10","-o","StrictHostKeyChecking=accept-new", + "-o","UserKnownHostsFile=/tmp/butler_known_hosts",host,cmd], capture_output=True, text=True, timeout=timeout) return r.returncode, r.stdout, r.stderr except _sp.TimeoutExpired: -- 2.49.1 From 4edd5502a2cafe3a286402726dede51133d242ee Mon Sep 17 00:00:00 2001 From: sascha Date: Wed, 22 Jul 2026 11:09:40 +0200 Subject: [PATCH 3/4] sync: butler.yaml for Butler 2.3.1 --- butler.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/butler.yaml b/butler.yaml index f91c3f9..ebd0c61 100644 --- a/butler.yaml +++ b/butler.yaml @@ -106,7 +106,7 @@ services: health_path: "/api/healthz" semaphore: - url: "http://10.4.1.116:8090" + url: "http://10.4.1.116:3010" auth: bearer key_file: semaphore vault_key: semaphore_token -- 2.49.1 From 40feb169e7d2eb4bb527bc7543cec85ac9402ce8 Mon Sep 17 00:00:00 2001 From: sascha Date: Wed, 22 Jul 2026 11:09:40 +0200 Subject: [PATCH 4/4] sync: tests/test_app.py for Butler 2.3.1 --- tests/test_app.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/test_app.py b/tests/test_app.py index c9f8e63..dc5f072 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -42,7 +42,7 @@ def test_health_exposes_current_version(): with TestClient(app.app) as client: response = client.get("/health") assert response.status_code == 200 - assert response.json()["version"] == app.VERSION == "2.3.0" + assert response.json()["version"] == app.VERSION == "2.3.1" def test_invalid_log_target_is_rejected_before_ssh(): @@ -145,6 +145,24 @@ def test_backup_item_reports_age_and_severity(): assert app._backup_item(1, "", "timeout")["state"] == "unknown" +def test_ssh_uses_writable_runtime_known_hosts(monkeypatch): + captured = {} + + class Result: + returncode = 0 + stdout = "ok" + stderr = "" + + def fake_run(args, **_kwargs): + captured["args"] = args + return Result() + + monkeypatch.setattr(app._sp, "run", fake_run) + assert app._ssh("sascha@example", "true", timeout=1)[0] == 0 + joined = " ".join(captured["args"]) + assert "UserKnownHostsFile=/tmp/butler_known_hosts" in joined + + def test_ssh_timeout_is_normalized_instead_of_crashing_collection(monkeypatch): def timeout(*_args, **_kwargs): raise app._sp.TimeoutExpired(cmd=["ssh"], timeout=1) -- 2.49.1