Add a stateful Proxmox API console and broad handler coverage beyond the
initial QEMU slice, backed by imported contracts for majors 6–9. - Implement durable handlers for access/auth, cluster, LXC, storage, HA, firewall, Ceph, SDN, ACME, notifications, pools, mapping, and node ops - Serve an interactive Web UI with catalog browsing, demo seed controls, and OpenAPI/help surfaces - Bundle PVE 6.4-15, 7.4-16, and 8.4.5 contract revisions alongside 9.2.3 - Support in-memory runtime contract Apply (POST /ui/api/contract/apply) so /version and /api2 routes follow the selected major until restart - Expand seed profiles (including demo-cluster), migrations 007–008, TLS gateway config, Compose/Makefile tooling, and compatibility evidence - Tighten .gitignore for macOS, hidden directories (.*/), and local secrets
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
"""Cluster ACME accounts and DNS plugins."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from app.api.errors import ApiError
|
||||
from app.api.registry import HandlerRegistry
|
||||
from app.handlers.common import cluster_metadata, save_cluster_metadata, subdirs, values
|
||||
|
||||
_DEFAULT_DIRECTORIES = [
|
||||
{
|
||||
"name": "Let's Encrypt V2",
|
||||
"url": "https://acme-v02.api.letsencrypt.org/directory",
|
||||
},
|
||||
{
|
||||
"name": "Let's Encrypt V2 Staging",
|
||||
"url": "https://acme-staging-v02.api.letsencrypt.org/directory",
|
||||
},
|
||||
]
|
||||
|
||||
_CHALLENGE_SCHEMA = [
|
||||
{
|
||||
"id": "dns",
|
||||
"name": "DNS plugin",
|
||||
"type": "dns",
|
||||
"fields": [{"name": "api", "type": "string"}],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _acme(metadata: dict[str, Any]) -> dict[str, Any]:
|
||||
current = metadata.setdefault(
|
||||
"acme",
|
||||
{"accounts": {}, "plugins": {}, "meta": {}},
|
||||
)
|
||||
if not isinstance(current, dict):
|
||||
current = {"accounts": {}, "plugins": {}, "meta": {}}
|
||||
metadata["acme"] = current
|
||||
current.setdefault("accounts", {})
|
||||
current.setdefault("plugins", {})
|
||||
current.setdefault("meta", {})
|
||||
return current
|
||||
|
||||
|
||||
def register_acme_handlers(registry: HandlerRegistry) -> None:
|
||||
async def index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||
return subdirs(
|
||||
"account",
|
||||
"challenge-schema",
|
||||
"directories",
|
||||
"meta",
|
||||
"plugins",
|
||||
"tos",
|
||||
)
|
||||
|
||||
async def account_list(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
metadata = await cluster_metadata(request)
|
||||
accounts = _acme(metadata)["accounts"]
|
||||
return [
|
||||
{
|
||||
"name": name,
|
||||
"contact": item.get("contact", []),
|
||||
"directory": item.get("directory"),
|
||||
}
|
||||
for name, item in sorted(accounts.items())
|
||||
]
|
||||
|
||||
async def account_create(request: Request, inputs: dict[str, Any]) -> None:
|
||||
payload = values(inputs)
|
||||
name = str(payload.get("name") or "default")
|
||||
metadata = await cluster_metadata(request)
|
||||
accounts = _acme(metadata)["accounts"]
|
||||
if name in accounts:
|
||||
raise ApiError(400, f"ACME account '{name}' already exists")
|
||||
accounts[name] = {
|
||||
"name": name,
|
||||
"contact": payload.get("contact"),
|
||||
"directory": payload.get("directory") or _DEFAULT_DIRECTORIES[0]["url"],
|
||||
"tos_url": payload.get("tos_url"),
|
||||
"eab-kid": payload.get("eab-kid"),
|
||||
# eab-hmac-key stored but never returned
|
||||
"eab-hmac-key": payload.get("eab-hmac-key"),
|
||||
"location": f"https://acme.example.local/acct/{name}",
|
||||
}
|
||||
await save_cluster_metadata(request, metadata)
|
||||
|
||||
async def account_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
name = str(values(inputs)["name"])
|
||||
metadata = await cluster_metadata(request)
|
||||
account = _acme(metadata)["accounts"].get(name)
|
||||
if not isinstance(account, dict):
|
||||
raise ApiError(404, "ACME account does not exist")
|
||||
return {
|
||||
"name": name,
|
||||
"contact": account.get("contact"),
|
||||
"directory": account.get("directory"),
|
||||
"tos": account.get("tos_url"),
|
||||
"location": account.get("location"),
|
||||
}
|
||||
|
||||
async def account_update(request: Request, inputs: dict[str, Any]) -> None:
|
||||
payload = values(inputs)
|
||||
name = str(payload["name"])
|
||||
metadata = await cluster_metadata(request)
|
||||
accounts = _acme(metadata)["accounts"]
|
||||
if name not in accounts:
|
||||
raise ApiError(404, "ACME account does not exist")
|
||||
if "contact" in payload:
|
||||
accounts[name]["contact"] = payload["contact"]
|
||||
await save_cluster_metadata(request, metadata)
|
||||
|
||||
async def account_delete(request: Request, inputs: dict[str, Any]) -> None:
|
||||
name = str(values(inputs)["name"])
|
||||
metadata = await cluster_metadata(request)
|
||||
accounts = _acme(metadata)["accounts"]
|
||||
if name not in accounts:
|
||||
raise ApiError(404, "ACME account does not exist")
|
||||
del accounts[name]
|
||||
await save_cluster_metadata(request, metadata)
|
||||
|
||||
async def plugins_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
metadata = await cluster_metadata(request)
|
||||
plugins = _acme(metadata)["plugins"]
|
||||
plugin_type = values(inputs).get("type")
|
||||
result = []
|
||||
for plugin_id, item in sorted(plugins.items()):
|
||||
if plugin_type and item.get("type") != plugin_type:
|
||||
continue
|
||||
result.append({"plugin": plugin_id, **{k: v for k, v in item.items() if k != "data"}})
|
||||
return result
|
||||
|
||||
async def plugins_create(request: Request, inputs: dict[str, Any]) -> None:
|
||||
payload = values(inputs)
|
||||
plugin_id = str(payload["id"])
|
||||
metadata = await cluster_metadata(request)
|
||||
plugins = _acme(metadata)["plugins"]
|
||||
if plugin_id in plugins:
|
||||
raise ApiError(400, f"ACME plugin '{plugin_id}' already exists")
|
||||
plugins[plugin_id] = {
|
||||
key: value for key, value in payload.items() if key not in {"delete", "digest"}
|
||||
}
|
||||
await save_cluster_metadata(request, metadata)
|
||||
|
||||
async def plugins_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
plugin_id = str(values(inputs)["id"])
|
||||
metadata = await cluster_metadata(request)
|
||||
plugin = _acme(metadata)["plugins"].get(plugin_id)
|
||||
if not isinstance(plugin, dict):
|
||||
raise ApiError(404, "ACME plugin does not exist")
|
||||
return {"id": plugin_id, **plugin}
|
||||
|
||||
async def plugins_update(request: Request, inputs: dict[str, Any]) -> None:
|
||||
payload = values(inputs)
|
||||
plugin_id = str(payload["id"])
|
||||
metadata = await cluster_metadata(request)
|
||||
plugins = _acme(metadata)["plugins"]
|
||||
if plugin_id not in plugins:
|
||||
raise ApiError(404, "ACME plugin does not exist")
|
||||
current = dict(plugins[plugin_id])
|
||||
for key in [
|
||||
item.strip() for item in str(payload.get("delete") or "").split(",") if item.strip()
|
||||
]:
|
||||
current.pop(key, None)
|
||||
for key, value in payload.items():
|
||||
if key in {"id", "delete", "digest"}:
|
||||
continue
|
||||
current[key] = value
|
||||
current["id"] = plugin_id
|
||||
plugins[plugin_id] = current
|
||||
await save_cluster_metadata(request, metadata)
|
||||
|
||||
async def plugins_delete(request: Request, inputs: dict[str, Any]) -> None:
|
||||
plugin_id = str(values(inputs)["id"])
|
||||
metadata = await cluster_metadata(request)
|
||||
plugins = _acme(metadata)["plugins"]
|
||||
if plugin_id not in plugins:
|
||||
raise ApiError(404, "ACME plugin does not exist")
|
||||
del plugins[plugin_id]
|
||||
await save_cluster_metadata(request, metadata)
|
||||
|
||||
async def directories(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return list(_DEFAULT_DIRECTORIES)
|
||||
|
||||
async def challenge_schema(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return list(_CHALLENGE_SCHEMA)
|
||||
|
||||
async def meta(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
directory = str(values(inputs).get("directory") or _DEFAULT_DIRECTORIES[0]["url"])
|
||||
metadata = await cluster_metadata(request)
|
||||
meta_store = _acme(metadata).setdefault("meta", {})
|
||||
payload = meta_store.setdefault(
|
||||
directory,
|
||||
{
|
||||
"termsOfService": f"{directory.rstrip('/')}/tos",
|
||||
"caaIdentities": ["letsencrypt.org"],
|
||||
},
|
||||
)
|
||||
await save_cluster_metadata(request, metadata)
|
||||
return dict(payload)
|
||||
|
||||
async def tos(request: Request, inputs: dict[str, Any]) -> str:
|
||||
directory = str(values(inputs).get("directory") or _DEFAULT_DIRECTORIES[0]["url"])
|
||||
result = await meta(request, {"values": {"directory": directory}, "provided": frozenset()})
|
||||
return str(result.get("termsOfService") or "")
|
||||
|
||||
registry.register("/cluster/acme", "GET", index)
|
||||
registry.register("/cluster/acme/account", "GET", account_list)
|
||||
registry.register("/cluster/acme/account", "POST", account_create)
|
||||
registry.register("/cluster/acme/account/{name}", "GET", account_get)
|
||||
registry.register("/cluster/acme/account/{name}", "PUT", account_update)
|
||||
registry.register("/cluster/acme/account/{name}", "DELETE", account_delete)
|
||||
registry.register("/cluster/acme/plugins", "GET", plugins_list)
|
||||
registry.register("/cluster/acme/plugins", "POST", plugins_create)
|
||||
registry.register("/cluster/acme/plugins/{id}", "GET", plugins_get)
|
||||
registry.register("/cluster/acme/plugins/{id}", "PUT", plugins_update)
|
||||
registry.register("/cluster/acme/plugins/{id}", "DELETE", plugins_delete)
|
||||
registry.register("/cluster/acme/directories", "GET", directories)
|
||||
registry.register("/cluster/acme/challenge-schema", "GET", challenge_schema)
|
||||
registry.register("/cluster/acme/meta", "GET", meta)
|
||||
registry.register("/cluster/acme/tos", "GET", tos)
|
||||
Reference in New Issue
Block a user