This repository has been archived on 2026-09-17. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
nixos-config/hosts/blunix/localServices/orga-agent/write_server.py
pijukka 33363c90e3 Agent-Manager-Rolle und Session-Lebenszyklus-Doku an verifizierten Ist-Zustand angleichen
Ausgelöst durch einen Soll/Ist-Abgleich des Agent-Managers gegen ein vom
Nutzer entworfenes Delegations-Diagramm: agent-manager.md behauptete
Session-Start/-Stop-Kontrolle, die es nicht hat (Starten ist
Orchestrator-Funktion, Schließen ist für niemanden verdrahtet). Neuer
Zielbild-Abschnitt in herdr-orchestration.md dokumentiert das vom Nutzer
gewünschte atomare Self-Close-Verhalten explizit als Entwurf mit den
technischen Voraussetzungen dafür, nicht als Ist-Zustand. orga.md an die
bereits getroffene 31.08.-Policy zu Kalender-Schreibrechten angeglichen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FTxMGYHPd7YrRBtgvRtqNk
2026-09-01 16:39:39 +02:00

275 lines
10 KiB
Python

#!/usr/bin/env python3
"""Secret-holding Orga CalDAV write service.
Full Radicale admin: create/update/delete VEVENTs in any collection, not
just calendar/public (see the "05-calendar-admin-full-access" rights rule
in hosts/pinix/localServices/calendar/radicale.nix). Update and delete
always require a concrete --collection/--uid the caller already knows --
there is no listing, search, or wildcard/bulk-delete operation here, by
design.
"""
import base64
import datetime as dt
import http.client
import json
import os
import re
import socket
import ssl
import sys
import urllib.parse
import uuid
from pathlib import Path
MAX_REQUEST = 16_384
CONTROL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
UID = re.compile(r"[A-Za-z0-9._@+-]{1,255}\Z")
# Alnum-first segments block ".."-style traversal outright; up to three
# segments covers every collection shape seen in radicale.nix ({user},
# {user}/{cal}, calendar/public) without hardcoding a specific one.
COLLECTION = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}(/[A-Za-z0-9][A-Za-z0-9._-]{0,63}){0,2}\Z")
class RequestError(Exception):
pass
def secret(path_variable):
value = Path(os.environ[path_variable]).read_text(encoding="utf-8").strip()
if not value:
raise RuntimeError("configured credential is empty")
return value
def text(value, name, maximum, allow_empty=False):
if not isinstance(value, str) or len(value) > maximum or CONTROL.search(value):
raise RequestError(f"invalid {name}")
if not allow_empty and not value.strip():
raise RequestError(f"missing {name}")
return value
def timestamp(value, name):
value = text(value, name, 40)
try:
parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as error:
raise RequestError(f"invalid {name}") from error
if parsed.tzinfo is None:
raise RequestError(f"{name} must include a timezone")
return parsed.astimezone(dt.timezone.utc)
def collection_path(value):
value = text(value, "collection", 200)
if not COLLECTION.fullmatch(value):
raise RequestError("invalid collection")
return value
def event_uid(value):
value = text(value, "uid", 255)
if not UID.fullmatch(value):
raise RequestError("invalid uid")
return value
def ics_escape(value):
return (
value.replace("\\", "\\\\")
.replace(";", "\\;")
.replace(",", "\\,")
.replace("\r\n", "\\n")
.replace("\n", "\\n")
.replace("\r", "\\n")
)
def build_vevent(uid, dtstamp, dtstart, dtend, title, description, location):
lines = [
"BEGIN:VCALENDAR", "VERSION:2.0",
"PRODID:-//0ff.space//orga-agent calendar-write//DE",
"CALSCALE:GREGORIAN", "BEGIN:VEVENT",
f"UID:{uid}", f"DTSTAMP:{dtstamp}", f"DTSTART:{dtstart}", f"DTEND:{dtend}",
f"SUMMARY:{ics_escape(title)}",
]
if description:
lines.append(f"DESCRIPTION:{ics_escape(description)}")
if location:
lines.append(f"LOCATION:{ics_escape(location)}")
lines += ["END:VEVENT", "END:VCALENDAR", ""]
return "\r\n".join(lines)
def base_url():
base = os.environ["ORGA_CALENDAR_URL"].rstrip("/")
parsed = urllib.parse.urlsplit(base)
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password:
raise RuntimeError("calendar URL must be credential-free HTTPS")
return parsed
def auth_header():
user = os.environ["ORGA_CALENDAR_USER"]
token = base64.b64encode(f"{user}:{secret('ORGA_CALENDAR_PASSWORD_FILE')}".encode()).decode()
return f"Basic {token}"
def item_href(parsed, collection, uid):
return f"{parsed.path.rstrip('/')}/{collection}/{uid}.ics"
def fetch_etag(connection, href):
connection.request("GET", href, headers={"Authorization": auth_header()})
response = connection.getresponse()
response.read(2_000_001)
if response.status == 404:
return None
if response.status != 200:
raise RuntimeError(f"calendar server returned HTTP {response.status} while reading event")
etag = response.getheader("ETag")
if not etag:
raise RuntimeError("calendar server did not return an ETag for the event")
return etag
def put_event(connection, href, ics_body, if_match=None, if_none_match=None):
headers = {
"Authorization": auth_header(),
"Content-Type": "text/calendar; charset=utf-8",
}
if if_match:
headers["If-Match"] = if_match
if if_none_match:
headers["If-None-Match"] = if_none_match
connection.request("PUT", href, body=ics_body.encode("utf-8"), headers=headers)
response = connection.getresponse()
response.read(65_536)
if response.status not in (200, 201, 204):
raise RuntimeError(f"calendar server returned HTTP {response.status} while writing event")
def delete_event(connection, href, if_match):
connection.request("DELETE", href, headers={"Authorization": auth_header(), "If-Match": if_match})
response = connection.getresponse()
response.read(65_536)
if response.status not in (200, 204):
raise RuntimeError(f"calendar server returned HTTP {response.status} while deleting event")
def open_connection(parsed):
return http.client.HTTPSConnection(parsed.hostname, parsed.port or 443, context=ssl.create_default_context(), timeout=20)
def validate_event_fields(request):
title = text(request.get("title"), "title", 500)
description = text(request.get("description", ""), "description", 20_000, allow_empty=True)
location = text(request.get("location", ""), "location", 1000, allow_empty=True)
start = timestamp(request.get("start"), "start")
end = timestamp(request.get("end"), "end")
if end <= start or end - start > dt.timedelta(days=31):
raise RequestError("event duration must be positive and at most 31 days")
return title, description, location, start, end
def calendar_write_request(request):
# Every field is validated before base_url()/open_connection() ever run,
# so a bad request never touches the network or the secret.
operation = request.get("operation")
if operation == "capabilities" and set(request) == {"operation"}:
return {
"ok": True, "capability": "calendar-write", "writes": True,
"scope": "admin: any collection", "operations": ["create", "update", "delete"],
}
if operation == "create":
if set(request) - {"operation", "collection", "title", "start", "end", "description", "location"}:
raise RequestError("unsupported create request")
collection = collection_path(request.get("collection"))
title, description, location, start, end = validate_event_fields(request)
elif operation == "update":
if set(request) - {"operation", "collection", "uid", "title", "start", "end", "description", "location"}:
raise RequestError("unsupported update request")
collection = collection_path(request.get("collection"))
uid = event_uid(request.get("uid"))
title, description, location, start, end = validate_event_fields(request)
elif operation == "delete":
if set(request) - {"operation", "collection", "uid"}:
raise RequestError("unsupported delete request")
collection = collection_path(request.get("collection"))
uid = event_uid(request.get("uid"))
else:
raise RequestError("unsupported calendar-write request")
parsed = base_url()
connection = open_connection(parsed)
try:
if operation == "create":
uid = f"{uuid.uuid4()}@{parsed.hostname}"
dtstamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
href = item_href(parsed, collection, uid)
body = build_vevent(
uid, dtstamp, start.strftime("%Y%m%dT%H%M%SZ"), end.strftime("%Y%m%dT%H%M%SZ"),
title, description, location,
)
# Fresh uuid4 UID makes a collision virtually impossible, but
# If-None-Match: * still guarantees this can never silently
# overwrite an existing item.
put_event(connection, href, body, if_none_match="*")
return {"ok": True, "operation": "create", "collection": collection, "uid": uid}
if operation == "update":
href = item_href(parsed, collection, uid)
etag = fetch_etag(connection, href)
if etag is None:
raise RequestError("event was not found; refusing to create one under 'update'")
dtstamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
body = build_vevent(
uid, dtstamp, start.strftime("%Y%m%dT%H%M%SZ"), end.strftime("%Y%m%dT%H%M%SZ"),
title, description, location,
)
put_event(connection, href, body, if_match=etag)
return {"ok": True, "operation": "update", "collection": collection, "uid": uid}
href = item_href(parsed, collection, uid)
etag = fetch_etag(connection, href)
if etag is None:
raise RequestError("event was not found")
delete_event(connection, href, if_match=etag)
return {"ok": True, "operation": "delete", "collection": collection, "uid": uid}
finally:
connection.close()
def serve():
listener = socket.socket(fileno=0)
while True:
connection, _ = listener.accept()
with connection:
try:
raw = b""
while not raw.endswith(b"\n") and len(raw) <= MAX_REQUEST:
chunk = connection.recv(4096)
if not chunk:
break
raw += chunk
if len(raw) > MAX_REQUEST:
raise RequestError("request is too large")
request = json.loads(raw)
if not isinstance(request, dict):
raise RequestError("request must be an object")
response = calendar_write_request(request)
except (RequestError, json.JSONDecodeError) as error:
response = {"ok": False, "error": str(error)}
except Exception:
response = {"ok": False, "error": "upstream service request failed"}
connection.sendall(json.dumps(response, ensure_ascii=False).encode("utf-8"))
def main():
serve()
if __name__ == "__main__":
sys.exit(main() or 0)