27f3603d8f
Save pending configuration, deployment, and documentation work.
277 lines
13 KiB
Python
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)
|