Skip to content

Commit bd68190

Browse files
phlogistonjohnmergify[bot]
authored andcommitted
kmip: add a kmip scope for keybridge servers
Signed-off-by: John Mulligan <[email protected]>
1 parent 4d69d45 commit bd68190

File tree

2 files changed

+169
-0
lines changed

2 files changed

+169
-0
lines changed

sambacc/kmip/__init__.py

Whitespace-only changes.

sambacc/kmip/scope.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
#
2+
# sambacc: a samba container configuration tool (and more)
3+
# Copyright (C) 2025 John Mulligan
4+
#
5+
# This program is free software: you can redistribute it and/or modify
6+
# it under the terms of the GNU General Public License as published by
7+
# the Free Software Foundation, either version 3 of the License, or
8+
# (at your option) any later version.
9+
#
10+
# This program is distributed in the hope that it will be useful,
11+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
# GNU General Public License for more details.
14+
#
15+
# You should have received a copy of the GNU General Public License
16+
# along with this program. If not, see <http://www.gnu.org/licenses/>
17+
#
18+
19+
import base64
20+
import binascii
21+
import contextlib
22+
import dataclasses
23+
import errno
24+
import logging
25+
import threading
26+
import time
27+
import typing
28+
29+
from kmip.pie.client import ProxyKmipClient # type: ignore[import]
30+
from kmip.pie.client import enums as kmip_enums
31+
32+
from sambacc.varlink.keybridge import EntryKind, ScopeInfo
33+
34+
35+
_logger = logging.getLogger(__name__)
36+
37+
# use monotonic if possible, otherwise fall back to traditional time.time.
38+
try:
39+
_tf = time.monotonic
40+
except AttributeError:
41+
_tf = time.time
42+
43+
44+
@dataclasses.dataclass
45+
class TLSPaths:
46+
cert: str
47+
key: str
48+
ca_cert: str
49+
50+
51+
@dataclasses.dataclass
52+
class _Value:
53+
"Cached value."
54+
value: bytes
55+
created: int
56+
57+
58+
class KMIPScope:
59+
"""Keybridge scope that proxies requests to a KMIP server."""
60+
61+
_cache_age = 30 # seconds
62+
63+
def __init__(
64+
self,
65+
kmip_name: str,
66+
*,
67+
hostnames: typing.Collection[str],
68+
port: int,
69+
tls_paths: TLSPaths,
70+
) -> None:
71+
self.kmip_name = kmip_name
72+
self.hostnames = hostnames
73+
self.port = port
74+
self.tls_paths = tls_paths
75+
self._kmip_version = kmip_enums.KMIPVersion.KMIP_1_2
76+
self._cache_lock = threading.Lock()
77+
self._kmip_cache: dict[str, _Value] = {}
78+
_logger.debug(
79+
"Created KMIP Scope with name=%r, hostnames=%r, port=%r, tls=%r",
80+
self.kmip_name,
81+
self.hostnames,
82+
self.port,
83+
self.tls_paths,
84+
)
85+
86+
def name(self) -> str:
87+
return f"kmip.{self.kmip_name}"
88+
89+
def about(self) -> ScopeInfo:
90+
return ScopeInfo(
91+
self.name(),
92+
default=False,
93+
kind="KMIP",
94+
description="KMIP Server Proxy",
95+
)
96+
97+
@contextlib.contextmanager
98+
def _client(self) -> typing.Iterator[ProxyKmipClient]:
99+
for hostname in self.hostnames:
100+
try:
101+
client = ProxyKmipClient(
102+
hostname=hostname,
103+
port=self.port,
104+
cert=self.tls_paths.cert,
105+
key=self.tls_paths.key,
106+
ca=self.tls_paths.ca_cert,
107+
kmip_version=self._kmip_version,
108+
)
109+
except OSError as err:
110+
_logger.warning("failed to connect to %r: %s", hostname, err)
111+
continue
112+
try:
113+
client.open()
114+
yield client
115+
finally:
116+
client.close()
117+
return
118+
raise OSError(errno.EHOSTUNREACH, "failed to connect to any host")
119+
120+
@contextlib.contextmanager
121+
def _cache(
122+
self, prune: bool = False
123+
) -> typing.Iterator[dict[str, _Value]]:
124+
with self._cache_lock:
125+
if prune:
126+
self._prune()
127+
yield self._kmip_cache
128+
129+
def _timestamp(self) -> int:
130+
return int(_tf())
131+
132+
def _prune(self) -> None:
133+
_now = self._timestamp()
134+
count = len(self._kmip_cache)
135+
self._kmip_cache = {
136+
k: v
137+
for k, v in self._kmip_cache.items()
138+
if _now < v.created + self._cache_age
139+
}
140+
_logger.debug(
141+
"pruned %s items from cache, now size %s",
142+
count - len(self._kmip_cache),
143+
len(self._kmip_cache),
144+
)
145+
146+
def get(self, key: str, kind: EntryKind) -> str:
147+
"""Get a value associated with the given key from the KMIP server or
148+
cache. If entry kind is B64 the (typically) binary data will be base64
149+
encoded. keybridge clients are expected to decode B64 on their side. If
150+
VALUE is given, return a hexlified version of the data similar to what
151+
the KMIP library does - for easier debugging.
152+
"""
153+
with self._cache(prune=True) as cache:
154+
if key in cache:
155+
_logger.debug("KMIP cache hit: %r", key)
156+
return _format(cache[key].value, kind)
157+
158+
with self._client() as client:
159+
result = client.get(key)
160+
_logger.debug("KMIP result for: %r", key)
161+
with self._cache() as cache:
162+
cache[key] = _Value(result.value, created=self._timestamp())
163+
return _format(result.value, kind)
164+
165+
166+
def _format(raw_data: bytes, kind: EntryKind) -> str:
167+
if kind is EntryKind.VALUE:
168+
return binascii.hexlify(raw_data).decode()
169+
return base64.b64encode(raw_data).decode()

0 commit comments

Comments
 (0)