chore: preserve current workspace changes
Save pending configuration, deployment, and documentation work.
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user