Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions dream-server/bin/dream-host-agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,8 @@ def do_POST(self):
self._handle_model_delete()
elif self.path == "/v1/compose/invalidate-cache":
self._handle_invalidate_compose_cache()
elif self.path == "/v1/env/update":
self._handle_env_update()
else:
json_response(self, 404, {"error": "Not found"})

Expand All @@ -609,6 +611,121 @@ def _handle_invalidate_compose_cache(self):
logger.info("compose-flags cache invalidated")
json_response(self, 200, {"status": "ok"})

def _handle_env_update(self):
"""Write a validated .env file. Dashboard-api delegates here because the
container mount is :ro — only the host agent may write secrets to disk.

Bypasses read_json_body() because the default 16 KB body limit truncates
real .env files (.env.example alone is ~11 KB)."""
if not check_auth(self):
return

client_ip = self.client_address[0] if hasattr(self, "client_address") else "?"
MAX_ENV_BODY = 65536 # env files routinely exceed the default 16 KB cap

try:
length = int(self.headers.get("Content-Length", "0"))
except (TypeError, ValueError):
logger.warning("env_update rejected: invalid Content-Length from %s", client_ip)
json_response(self, 400, {"error": "Invalid Content-Length"})
return
if length <= 0:
logger.warning("env_update rejected: empty body from %s", client_ip)
json_response(self, 400, {"error": "Empty body"})
return
if length > MAX_ENV_BODY:
logger.warning("env_update rejected: body too large (%d bytes) from %s", length, client_ip)
json_response(self, 413, {"error": f"Body too large: {length} > {MAX_ENV_BODY}"})
return
try:
raw = self.rfile.read(length)
body = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, ValueError, json.JSONDecodeError) as exc:
logger.warning("env_update rejected: invalid JSON from %s: %s", client_ip, exc)
json_response(self, 400, {"error": f"Invalid JSON: {exc}"})
return

raw_text = body.get("raw_text")
if not isinstance(raw_text, str) or not raw_text.strip():
logger.warning("env_update rejected: raw_text missing/empty from %s", client_ip)
json_response(self, 400, {"error": "raw_text required"})
return
backup = body.get("backup", True)

schema_path = INSTALL_DIR / ".env.schema.json"
if not schema_path.exists():
logger.warning("env_update rejected: schema missing at %s (request from %s)", schema_path, client_ip)
json_response(self, 500, {"error": f".env.schema.json not found at {schema_path}"})
return
try:
with open(schema_path, encoding="utf-8") as f:
schema = json.load(f)
except (json.JSONDecodeError, OSError) as exc:
logger.warning("env_update rejected: failed to read schema (request from %s): %s", client_ip, exc)
json_response(self, 500, {"error": f"Failed to read .env.schema.json: {exc}"})
return
allowed_keys = set(schema.get("properties", {}).keys())

for line in raw_text.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if "=" not in stripped:
logger.warning("env_update rejected: malformed line %r from %s", stripped[:80], client_ip)
json_response(self, 400, {"error": f"Malformed line: {stripped[:80]}"})
return
key, _, value = stripped.partition("=")
key = key.strip()
if not re.match(r'^[A-Za-z_][A-Za-z0-9_]*$', key):
logger.warning("env_update rejected: invalid key name %r from %s", key[:40], client_ip)
json_response(self, 400, {"error": f"Invalid key name: {key[:40]}"})
return
if key not in allowed_keys:
# Warn but accept — extension install hooks and GPU pinning write
# keys that are not in the core schema (e.g. JWT_SECRET from
# LibreChat, COMFYUI_GPU_UUID from the installer). Rejecting
# them breaks the dashboard Settings save for any install that
# has ever enabled an extension.
logger.info("env_update: non-schema key %r from %s (accepted)", key, client_ip)
# Defense in depth: reject values containing control chars (null bytes,
# escape sequences, etc.). splitlines() already consumed \n/\r/\u2028/\u2029;
# this catches the residual edge cases flagged by security review.
if any(ord(c) < 32 and c != "\t" for c in value):
logger.warning("env_update rejected: control char in value for key %r from %s", key, client_ip)
json_response(self, 400, {"error": f"Value contains control characters for key: {key}"})
return

# Coordinate with model activation, which also writes .env under this lock.
if not _model_activate_lock.acquire(blocking=False):
logger.warning("env_update rejected: lock contention from %s", client_ip)
json_response(self, 409, {"error": "Model activation or another env update in progress; try again shortly"})
return

env_path = INSTALL_DIR / ".env"
backup_relative_path = None
try:
if backup and env_path.exists():
backup_dir = DATA_DIR / "config-backups"
backup_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
backup_path = backup_dir / f".env.backup.{timestamp}"
shutil.copy2(env_path, backup_path)
backup_relative_path = f"data/{backup_path.relative_to(DATA_DIR).as_posix()}"

payload_text = raw_text if raw_text.endswith("\n") else raw_text + "\n"
tmp_path = env_path.with_name(".env.tmp")
tmp_path.write_text(payload_text, encoding="utf-8")
os.replace(str(tmp_path), str(env_path))
except OSError as exc:
logger.warning("env_update OSError from %s: %s", client_ip, exc)
json_response(self, 500, {"error": str(exc)})
return
finally:
_model_activate_lock.release()

logger.info(".env updated via host agent from %s (backup=%s)", client_ip, backup_relative_path or "none")
json_response(self, 200, {"status": "ok", "backup_path": backup_relative_path})

def _handle_core_recreate(self):
if not check_auth(self):
return
Expand Down
2 changes: 1 addition & 1 deletion dream-server/docker-compose.base.yml
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ services:
- ./scripts:/dream-server/scripts:ro
- ./config:/dream-server/config:ro
- ./extensions:/dream-server/extensions:ro
- ./.env:/dream-server/.env
- ./.env:/dream-server/.env:ro
- ./.env.example:/dream-server/.env.example:ro
- ./.env.schema.json:/dream-server/.env.schema.json:ro
- ./data:/data
Expand Down
99 changes: 34 additions & 65 deletions dream-server/extensions/services/dashboard-api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -709,7 +709,7 @@ def _render_env_from_values(values: dict[str, str]) -> str:

output_lines.append(line)

extras = [(key, value) for key, value in values.items() if key not in seen and value != ""]
extras = [(key, value) for key, value in values.items() if key not in seen]
if extras:
if output_lines and output_lines[-1] != "":
output_lines.append("")
Expand Down Expand Up @@ -744,6 +744,23 @@ def _call_agent_core_recreate(service_ids: list[str]) -> dict[str, Any]:
return json.loads(response.read().decode("utf-8"))


def _call_agent_env_update(raw_text: str) -> dict[str, Any]:
"""Route .env writes through the host agent (filesystem is :ro in container)."""
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {DREAM_AGENT_KEY}",
}
data = json.dumps({"raw_text": raw_text, "backup": True}).encode("utf-8")
request = urllib.request.Request(
f"{AGENT_URL}/v1/env/update",
data=data,
headers=headers,
method="POST",
)
with urllib.request.urlopen(request, timeout=60) as response:
return json.loads(response.read().decode("utf-8"))


def _check_host_agent_available() -> bool:
try:
with urllib.request.urlopen(f"{AGENT_URL}/health", timeout=3) as response:
Expand Down Expand Up @@ -804,45 +821,6 @@ def _relative_install_path(path: Path) -> str:
return str(path).replace("\\", "/")


def _resolve_env_backup_root() -> Path:
backup_root = Path(DATA_DIR) / "config-backups"
backup_root.mkdir(parents=True, exist_ok=True)
return backup_root


def _display_backup_path(path: Path) -> str:
data_root = Path(DATA_DIR)
try:
return f"data/{path.relative_to(data_root).as_posix()}"
except ValueError:
return str(path).replace("\\", "/")


def _write_text_atomic(path: Path, raw_text: str):
temp_path = path.with_name(f"{path.name}.tmp-{os.getpid()}-{int(time.time() * 1000)}")
try:
try:
temp_path.write_text(raw_text, encoding="utf-8")
if path.exists():
try:
shutil.copymode(path, temp_path)
except OSError:
pass
os.replace(temp_path, path)
return
except PermissionError:
if not path.exists():
raise
path.write_text(raw_text, encoding="utf-8")
return
finally:
try:
if temp_path.exists():
temp_path.unlink()
except OSError:
pass


def _prepare_env_save(payload: dict[str, Any]) -> tuple[str, list[dict[str, Any]], dict[str, Any]]:
mode = payload.get("mode", "form")
env_path = _resolve_runtime_env_path()
Expand Down Expand Up @@ -1365,7 +1343,6 @@ async def api_settings_env_save(
payload: dict[str, Any] = Body(...),
api_key: str = Depends(verify_api_key),
):
env_path = _resolve_runtime_env_path()
raw_text, issues, apply_plan = await asyncio.to_thread(_prepare_env_save, payload)
if issues:
raise HTTPException(
Expand All @@ -1376,35 +1353,27 @@ async def api_settings_env_save(
},
)

env_path.parent.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
backup_root = await asyncio.to_thread(_resolve_env_backup_root)
backup_path = backup_root / f".env.backup.{timestamp}"
backup_relative = None

if env_path.exists():
try:
shutil.copy2(env_path, backup_path)
backup_relative = _display_backup_path(backup_path)
except OSError as exc:
raise HTTPException(
status_code=500,
detail={
"message": "Could not create a configuration backup before saving.",
"reason": str(exc),
},
) from exc

try:
await asyncio.to_thread(_write_text_atomic, env_path, raw_text)
agent_resp = await asyncio.to_thread(_call_agent_env_update, raw_text)
except urllib.error.HTTPError as exc:
detail = f"Host agent returned HTTP {exc.code}."
try:
err_payload = json.loads(exc.read().decode("utf-8"))
detail = err_payload.get("error", detail)
except Exception:
pass
raise HTTPException(status_code=503, detail={"message": detail}) from exc
except urllib.error.URLError as exc:
raise HTTPException(
status_code=503,
detail={"message": "Dream host agent is not reachable. Start the host agent, then try again."},
) from exc
except OSError as exc:
raise HTTPException(
status_code=500,
detail={
"message": "Could not write the updated environment file.",
"reason": str(exc),
},
detail={"message": "Could not contact host agent to write environment file.", "reason": str(exc)},
) from exc
backup_relative = agent_resp.get("backup_path")

_clear_settings_caches()
result = await asyncio.to_thread(
Expand Down
Loading
Loading