Files
hrynco-notification-service/docker/kubernetes/shared-notification/prepare-seq-key.py
T
agrynco 27f3603d8f chore: preserve current workspace changes
Save pending configuration, deployment, and documentation work.
2026-10-03 00:56:28 +03:00

277 lines
13 KiB
Python

#!/usr/bin/env python3
"""Provision only the dedicated Notification ingest key; never print credentials."""
import contextlib
import copy
from datetime import datetime, timezone
import http.cookiejar
import json
import os
from pathlib import Path
import re
import secrets
import stat
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
BASE = "http://192.168.2.44:30534/"
CREDENTIALS = Path("/home/devops/seq-first/credentials.json")
TOKEN_FILE = Path("/home/devops/notification-private/seq-key.txt")
TITLE = "HrynCo Notification Service"
PROPERTIES = [{"Name": "Environment", "Value": "SharedNotification"},
{"Name": "Service", "Value": "HrynCo.NotificationService"}]
MARKER = "NotificationIngestKeyId"
KEY_LIST = "api/apikeys?shared=true"
def allowed_path(method, path):
methods = {"api/users/login": "POST", KEY_LIST: "GET", "api/apikeys/template": "GET",
"api/apikeys/": "POST", "api/events/raw": "POST"}
return methods.get(path) == method or (method == "GET" and re.fullmatch(
r"api/events\?count=2&filter=ProvisioningCheckId%20%3D%20%27[a-f0-9]{32}%27", path)) or (
method == "PUT" and re.fullmatch(r"api/apikeys/apikey-[a-z0-9-]+", path))
def require(condition, message):
if not condition:
raise RuntimeError(message)
class NoRedirects(urllib.request.HTTPRedirectHandler):
def redirect_request(self, request, fp, code, message, headers, new_url):
raise RuntimeError("unexpected Seq redirect denied")
class Client:
def __init__(self):
self.session = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirects(),
urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
self.csrf = None
def request(self, method, path, body=None, key=None):
require(allowed_path(method, path), "unexpected Seq API path")
if method == "PUT":
verify_key(body)
require(path == "api/apikeys/" + body["Id"], "dedicated Seq update path mismatch")
headers = {}
if isinstance(body, dict):
body = json.dumps(body).encode()
headers["Content-Type"] = "application/json"
if key:
require(path == "api/events/raw", "ingestion token used outside ingestion")
headers["X-Seq-ApiKey"] = key
headers["Content-Type"] = "application/vnd.serilog.clef"
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirects())
else:
opener = self.session
if self.csrf and method != "GET":
headers["X-Seq-CsrfToken"] = self.csrf
request = urllib.request.Request(BASE + path, data=body, headers=headers, method=method)
try:
with opener.open(request, timeout=15) as response:
require(response.geturl() == BASE + path, "unexpected Seq response endpoint")
raw = response.read(1024 * 1024 + 1)
require(len(raw) <= 1024 * 1024, "unexpected Seq response size")
require(response.status in (200, 201, 202, 204), "Seq request failed")
return json.loads(raw) if raw and "json" in response.headers.get("Content-Type", "") else None
except urllib.error.HTTPError as error:
code = error.code
error.close()
raise RuntimeError("Seq request denied (HTTP " + str(code) + ")") from None
except (urllib.error.URLError, TimeoutError):
raise RuntimeError("trusted Seq endpoint unavailable") from None
def login(self, credentials):
response = self.request("POST", "api/users/login", {
"Username": credentials["adminUsername"], "Password": credentials["adminPassword"]})
require(response["Username"] == credentials["adminUsername"] and
isinstance(response.get("CsrfToken"), str) and response["CsrfToken"], "Seq login identity/CSRF mismatch")
self.csrf = response["CsrfToken"]
def private_directory(path):
require(not path.is_symlink(), "private directory must not be a symlink")
path.mkdir(mode=0o700, parents=True, exist_ok=True)
require(path.is_dir(), "private directory is not a directory")
if os.name == "posix":
info = path.stat()
require(info.st_uid == os.getuid() and stat.S_IMODE(info.st_mode) == 0o700,
"private directory ownership/mode must match devops/0700")
def private_file(path):
require(not path.is_symlink() and path.is_file(), "private file missing or unexpected type")
if os.name == "posix":
info = path.stat()
require(info.st_uid == os.getuid() and stat.S_IMODE(info.st_mode) == 0o600,
"private file ownership/mode must match devops/0600")
@contextlib.contextmanager
def key_lock(directory):
path = directory / ".seq-key.lock"
try:
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
except FileExistsError:
raise RuntimeError("Seq key lock exists; inspect active run before removing stale lock") from None
try:
os.close(fd)
yield
finally:
path.unlink()
def verify_key(value, allow_unmarked=False):
require(isinstance(value, dict), "unexpected dedicated Seq key response")
require(value.get("Title") == TITLE and isinstance(value.get("Id"), str) and
re.fullmatch(r"apikey-[a-z0-9-]+", value["Id"]), "dedicated Seq key identity mismatch")
require(value.get("AssignedPermissions") == ["Ingest"], "dedicated Seq key must grant only Ingest")
properties = value.get("InputSettings", {}).get("AppliedProperties")
expected = PROPERTIES + [{"Name": MARKER, "Value": value["Id"]}]
unmarked = allow_unmarked and isinstance(properties, list) and len(properties) == len(PROPERTIES) and (
sorted(properties, key=lambda x: x["Name"]) == sorted(PROPERTIES, key=lambda x: x["Name"]))
require(isinstance(properties, list) and (unmarked or (
len(properties) == len(expected) and sorted(properties, key=lambda x: x["Name"]) == sorted(expected, key=lambda x: x["Name"]))),
"dedicated Seq key stamped properties mismatch")
def dedicated_keys(client):
values = client.request("GET", KEY_LIST)
require(isinstance(values, list), "unexpected Seq key list")
matches = [value for value in values if value.get("Title") == TITLE]
require(len(matches) <= 1, "ambiguous dedicated Seq keys; no key changed")
return matches
def marker_unique(values, key_id):
for value in values:
if value.get("Id") == key_id:
continue
properties = value.get("InputSettings", {}).get("AppliedProperties") or []
require(isinstance(properties, list) and all(isinstance(p, dict) for p in properties), "unexpected Seq marker property schema")
require(not any(p.get("Name") == MARKER and p.get("Value") == key_id for p in properties),
"dedicated Seq identity marker collision; no foreign key changed")
def ensure_identity_marker(client, key_id):
values = client.request("GET", KEY_LIST)
require(isinstance(values, list), "unexpected Seq key list")
targets = [v for v in values if v.get("Id") == key_id and v.get("Title") == TITLE]
require(len(targets) == 1, "dedicated Seq key changed before guarded update")
target = targets[0]
verify_key(target, allow_unmarked=True)
marker_unique(values, key_id)
expected_settings = copy.deepcopy(target["InputSettings"])
if not any(p["Name"] == MARKER for p in expected_settings["AppliedProperties"]):
expected_settings["AppliedProperties"].append({"Name": MARKER, "Value": key_id})
updated = copy.deepcopy(target)
updated["InputSettings"] = expected_settings
# Seq's documented UpdateAsync cannot change the token. Preserve every
# existing entity field; only this dedicated key's one stamp is added.
client.request("PUT", "api/apikeys/" + key_id, updated)
readback = client.request("GET", KEY_LIST)
require(isinstance(readback, list), "unexpected Seq key readback")
marker_unique(readback, key_id)
targets = [v for v in readback if v.get("Id") == key_id and v.get("Title") == TITLE]
require(len(targets) == 1, "dedicated Seq key readback missing")
result = targets[0]
verify_key(result)
require(result.get("InputSettings") == expected_settings and
all(result.get(field) == target.get(field) for field in ("AssignedPermissions", "OwnerId", "IsDefault", "Token", "TokenPrefix")),
"dedicated Seq update changed settings beyond identity marker")
return result
def verify_canary(client, check_id, expected_key):
query = "api/events?count=2&filter=" + urllib.parse.quote("ProvisioningCheckId = '" + check_id + "'")
events = []
for attempt in range(20):
events = client.request("GET", query)
require(isinstance(events, list), "unexpected Seq canary readback")
if events:
break
if attempt < 19:
time.sleep(1)
require(len(events) == 1 and isinstance(events[0], dict), "Seq canary readback must contain exactly one event")
event = events[0]
# Seq's official EventEntity exposes Properties as EventPropertyPart{Name,Value}.
parts = event.get("Properties")
require(isinstance(parts, list) and all(isinstance(p, dict) and "Name" in p and "Value" in p for p in parts),
"unexpected Seq canary property schema")
properties = {p["Name"]: p["Value"] for p in parts}
require(len(properties) == len(parts), "ambiguous Seq canary properties")
require(properties.get("ProvisioningCheckId") == check_id, "Seq canary correlation mismatch")
# The canary payload omits this server-stamped, globally unique key marker.
# Installed Seq exposes neither the original token nor ApiKeyId on EventEntity.
require(properties.get(MARKER) == expected_key["Id"], "Seq canary token belongs to a different or unverified key")
actual_key = event.get("ApiKeyId", properties.get("ApiKeyId"))
if actual_key is not None:
require(actual_key == expected_key["Id"], "Seq canary provenance conflicts with identity marker")
require(properties.get("Environment") == "SharedNotification" and
properties.get("Service") == "HrynCo.NotificationService", "Seq canary stamped identity mismatch")
def provision(client, token_file=TOKEN_FILE):
private_directory(token_file.parent)
with key_lock(token_file.parent):
existing = dedicated_keys(client)
if existing:
verify_key(existing[0], allow_unmarked=True)
require(token_file.exists(), "dedicated remote key exists but local token is missing; recover original file; no rotation performed")
if token_file.exists():
private_file(token_file)
token = token_file.read_text(encoding="utf-8").strip()
require(re.fullmatch(r"[a-f0-9]{64}", token), "private token format mismatch; no rotation performed")
else:
token = secrets.token_hex(32)
# Persist BEFORE POST: a timed-out create can be recovered without rotation.
fd = os.open(token_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as stream:
stream.write(token + "\n")
stream.flush()
os.fsync(stream.fileno())
if not existing:
template = client.request("GET", "api/apikeys/template")
require(isinstance(template, dict) and isinstance(template.get("InputSettings"), dict), "unexpected Seq key template")
template.update({"Title": TITLE, "Token": token, "AssignedPermissions": ["Ingest"]})
template["InputSettings"]["AppliedProperties"] = PROPERTIES
created = client.request("POST", "api/apikeys/", template)
verify_key(created, allow_unmarked=True)
readback = dedicated_keys(client)
require(len(readback) == 1, "dedicated Seq key readback missing")
dedicated = ensure_identity_marker(client, readback[0]["Id"])
# A valid foreign token must not pass. The marker is applied by Seq only,
# never supplied by this canary's body, and is unique across all keys.
canary = {"@t": datetime.now(timezone.utc).isoformat(),
"@mt": "HrynCo Notification Service ingest key verification",
"Application": "hrynco-notification-service-provisioning",
"ProvisioningCheckId": secrets.token_hex(16)}
client.request("POST", "api/events/raw", json.dumps(canary).encode() + b"\n", key=token)
verify_canary(client, canary["ProvisioningCheckId"], dedicated)
return dedicated["Id"]
def main():
require(sys.platform == "linux" and os.getuid() != 0, "run as devops on mini-pc-2")
import pwd
require(pwd.getpwuid(os.getuid()).pw_name == "devops", "run as devops on mini-pc-2")
private_file(CREDENTIALS)
credentials = json.loads(CREDENTIALS.read_text(encoding="utf-8"))
client = Client()
client.login(credentials)
provision(client)
print("Dedicated Notification Seq key/marker verified; private token preserved; other keys and retention unchanged.")
if __name__ == "__main__":
try:
main()
except (RuntimeError, OSError, ValueError, KeyError, TypeError) as error:
# RuntimeError messages are fixed gates; never API responses or file contents.
message = str(error) if isinstance(error, RuntimeError) else type(error).__name__
print("STOP: " + message, file=sys.stderr)
sys.exit(1)