diff --git a/daemon/server.py b/daemon/server.py index 047d8af..5bb3a14 100644 --- a/daemon/server.py +++ b/daemon/server.py @@ -4,6 +4,7 @@ import json import os import signal import socket +import struct import traceback from registry import Registry, RegistryError @@ -17,6 +18,8 @@ SOCKET_PATH = os.environ.get( server_socket = None registry = None pacemaker_resource_id = None +pacemaker_peer_uid = None +pacemaker_peer_gid = None PROTOCOL_VERSION = 1 @@ -65,7 +68,66 @@ def load_pacemaker_resource_binding( return resource_id -def handle_request(req): +def load_pacemaker_peer_authorization( + environ=None, +): + """ + Load the peer credentials that authorize Pacemaker resource actions. + + In the rootless keep-id user namespace, host root is intentionally + unmapped and therefore appears as the configured overflow UID/GID. + Socket path permissions remain part of this authorization boundary. + """ + if environ is None: + environ = os.environ + + raw_uid = environ.get("PACEMAKER_PEER_UID") + raw_gid = environ.get("PACEMAKER_PEER_GID") + + if raw_uid is None or raw_gid is None: + raise RegistryError( + "PACEMAKER_PEER_UID and " + "PACEMAKER_PEER_GID must both be configured" + ) + + try: + peer_uid = int(raw_uid) + peer_gid = int(raw_gid) + + except ValueError as exc: + raise RegistryError( + "PACEMAKER_PEER_UID and " + "PACEMAKER_PEER_GID must be integers" + ) from exc + + if peer_uid < 0 or peer_gid < 0: + raise RegistryError( + "PACEMAKER peer credentials must be non-negative" + ) + + return peer_uid, peer_gid + + +def get_peer_credentials(conn): + raw = conn.getsockopt( + socket.SOL_SOCKET, + socket.SO_PEERCRED, + struct.calcsize("3i"), + ) + + pid, uid, gid = struct.unpack("3i", raw) + + return { + "pid": pid, + "uid": uid, + "gid": gid, + } + + +def handle_request( + req, + peer_credentials=None, +): if not isinstance(req, dict): return protocol_response({ "ok": False, @@ -133,6 +195,35 @@ def handle_request(req): "action": action, }) + if peer_credentials is None: + return protocol_response({ + "ok": False, + "error_code": "peer_credentials_unavailable", + "error": ( + "peer credentials are required for " + "Pacemaker resource actions" + ), + "resource": resource, + "action": action, + }) + + if ( + peer_credentials.get("uid") + != pacemaker_peer_uid + or peer_credentials.get("gid") + != pacemaker_peer_gid + ): + return protocol_response({ + "ok": False, + "error_code": "peer_credential_mismatch", + "error": ( + "peer credentials are not authorized " + "for Pacemaker resource actions" + ), + "resource": resource, + "action": action, + }) + return protocol_response( registry.execute( resource, @@ -170,16 +261,27 @@ def serve(): global server_socket global registry global pacemaker_resource_id + global pacemaker_peer_uid + global pacemaker_peer_gid registry = Registry() pacemaker_resource_id = ( load_pacemaker_resource_binding(registry) ) + ( + pacemaker_peer_uid, + pacemaker_peer_gid, + ) = load_pacemaker_peer_authorization() + + socket_directory = os.path.dirname( + SOCKET_PATH + ) os.makedirs( - os.path.dirname(SOCKET_PATH), + socket_directory, exist_ok=True, ) + os.chmod(socket_directory, 0o700) cleanup_socket() @@ -192,7 +294,7 @@ def serve(): ) server_socket.bind(SOCKET_PATH) - os.chmod(SOCKET_PATH, 0o660) + os.chmod(SOCKET_PATH, 0o600) server_socket.listen(16) print( @@ -212,12 +314,23 @@ def serve(): flush=True, ) + print( + "Pacemaker peer authorization: " + f"uid={pacemaker_peer_uid} " + f"gid={pacemaker_peer_gid}", + flush=True, + ) + try: while True: conn, _ = server_socket.accept() with conn: try: + peer_credentials = ( + get_peer_credentials(conn) + ) + raw = b"" while b"\n" not in raw: @@ -232,7 +345,10 @@ def serve(): raw.decode("utf-8").strip() ) - response = handle_request(req) + response = handle_request( + req, + peer_credentials, + ) except ( RegistryError, diff --git a/postgres.env b/postgres.env index dd32d44..b835300 100644 --- a/postgres.env +++ b/postgres.env @@ -1,5 +1,7 @@ RESOURCE_ID=postgresql-ha PACEMAKER_RESOURCE_ID=postgresql-ha +PACEMAKER_PEER_UID=65534 +PACEMAKER_PEER_GID=65534 PLUGIN=postgresql MANAGED_CONTAINERD_SOCKET=/run/managed-containerd/managed-containerd.sock diff --git a/tests/test-peer-authorization.py b/tests/test-peer-authorization.py new file mode 100755 index 0000000..5018bb9 --- /dev/null +++ b/tests/test-peer-authorization.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 + +import os +import socket +import sys + +sys.path.insert(0, "daemon") + +import server +from registry import RegistryError + + +class FakeRegistry: + def __init__(self): + self.resources = { + "postgresql-ha": object(), + } + self.executions = [] + + def execute(self, resource, action): + self.executions.append((resource, action)) + + return { + "ok": True, + "resource": resource, + "plugin": "postgresql", + "action": action, + "result": {"accepted": True}, + } + + +def require(condition, message): + if not condition: + raise SystemExit("FAIL: " + message) + + +def require_registry_error(callback, message): + try: + callback() + except RegistryError: + return + + raise SystemExit("FAIL: " + message) + + +print("===== SO_PEERCRED AUTHORIZATION =====") + +require_registry_error( + lambda: server.load_pacemaker_peer_authorization({}), + "missing peer configuration was accepted", +) +print("missing_configuration rejected") + +require_registry_error( + lambda: server.load_pacemaker_peer_authorization({ + "PACEMAKER_PEER_UID": "invalid", + "PACEMAKER_PEER_GID": "65534", + }), + "invalid peer configuration was accepted", +) +print("invalid_configuration rejected") + +peer_uid, peer_gid = ( + server.load_pacemaker_peer_authorization({ + "PACEMAKER_PEER_UID": "65534", + "PACEMAKER_PEER_GID": "65534", + }) +) + +require( + (peer_uid, peer_gid) == (65534, 65534), + "valid peer configuration was not loaded", +) +print("valid_configuration accepted") + +left, right = socket.socketpair() + +try: + observed = server.get_peer_credentials(left) +finally: + left.close() + right.close() + +require( + observed["uid"] == os.geteuid(), + "SO_PEERCRED UID parsing is incorrect", +) +require( + observed["gid"] == os.getegid(), + "SO_PEERCRED GID parsing is incorrect", +) +require( + isinstance(observed["pid"], int), + "SO_PEERCRED PID parsing is incorrect", +) +print("kernel_credentials parsed") + +registry = FakeRegistry() + +server.registry = registry +server.pacemaker_resource_id = "postgresql-ha" +server.pacemaker_peer_uid = peer_uid +server.pacemaker_peer_gid = peer_gid + +request = { + "protocol_version": 1, + "resource": "postgresql-ha", + "action": "monitor", +} + +response = server.handle_request( + request, + { + "pid": 0, + "uid": 65534, + "gid": 65534, + }, +) + +require( + response["ok"] is True, + "authorized root peer was rejected", +) +require( + registry.executions + == [("postgresql-ha", "monitor")], + "authorized request did not execute exactly once", +) +print("authorized_root_peer executed") + +response = server.handle_request( + request, + { + "pid": os.getpid(), + "uid": 1000, + "gid": 1000, + }, +) + +require( + response["ok"] is False, + "David peer was authorized", +) +require( + response["error_code"] + == "peer_credential_mismatch", + "David rejection has the wrong classification", +) +require( + registry.executions + == [("postgresql-ha", "monitor")], + "unauthorized request reached the registry", +) +print("david_peer rejected") + +response = server.handle_request(request) + +require( + response["ok"] is False, + "credential-free request was authorized", +) +require( + response["error_code"] + == "peer_credentials_unavailable", + "missing credentials have the wrong classification", +) +require( + registry.executions + == [("postgresql-ha", "monitor")], + "credential-free request reached the registry", +) +print("missing_credentials rejected") + +print() +print("PEER_AUTHORIZATION_TEST_PASS=1") diff --git a/tests/test-resource-binding.py b/tests/test-resource-binding.py index 545103d..2b1537e 100755 --- a/tests/test-resource-binding.py +++ b/tests/test-resource-binding.py @@ -95,6 +95,14 @@ print("valid_binding accepted") server.registry = registry server.pacemaker_resource_id = binding +server.pacemaker_peer_uid = 65534 +server.pacemaker_peer_gid = 65534 + +authorized_peer = { + "pid": 1234, + "uid": 65534, + "gid": 65534, +} ping = server.handle_request({ "protocol_version": 1, @@ -113,11 +121,14 @@ require( print("ping_binding reported") -response = server.handle_request({ - "protocol_version": 1, - "resource": "postgresql-ha", - "action": "monitor", -}) +response = server.handle_request( + { + "protocol_version": 1, + "resource": "postgresql-ha", + "action": "monitor", + }, + authorized_peer, +) require( response["ok"] is True, @@ -131,11 +142,14 @@ require( print("matching_resource executed") -response = server.handle_request({ - "protocol_version": 1, - "resource": "different-resource", - "action": "monitor", -}) +response = server.handle_request( + { + "protocol_version": 1, + "resource": "different-resource", + "action": "monitor", + }, + authorized_peer, +) require( response["ok"] is False,