authkeys 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- authkeys/__init__.py +382 -0
- authkeys/__main__.py +10 -0
- authkeys/cache.py +211 -0
- authkeys/cli.py +326 -0
- authkeys/config.py +107 -0
- authkeys/groupmembers.py +10 -0
- authkeys/py.typed +0 -0
- authkeys/server.py +135 -0
- authkeys/sources/__init__.py +53 -0
- authkeys/sources/file.py +28 -0
- authkeys/sources/github.py +42 -0
- authkeys/sources/http.py +67 -0
- authkeys/sources/ldap.py +134 -0
- authkeys/usermaps.py +9 -0
- authkeys/utils.py +49 -0
- authkeys-0.4.0.dist-info/METADATA +170 -0
- authkeys-0.4.0.dist-info/RECORD +20 -0
- authkeys-0.4.0.dist-info/WHEEL +4 -0
- authkeys-0.4.0.dist-info/entry_points.txt +2 -0
- authkeys-0.4.0.dist-info/licenses/LICENSE +21 -0
authkeys/__init__.py
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
"""authkeys: pluggable ``AuthorizedKeysCommand`` provider for OpenSSH.
|
|
2
|
+
|
|
3
|
+
Resolves a user's authorized SSH keys from one or more configured *sources*
|
|
4
|
+
(local files, an HTTP endpoint, LDAP certificates, ...), with optional TTL
|
|
5
|
+
caching and user/group aliasing. Designed to be driven either from the CLI as an
|
|
6
|
+
``AuthorizedKeysCommand`` or as a long-running key server (``authkeys serve``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import threading
|
|
11
|
+
from configparser import SectionProxy
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Callable, Dict, Iterable, List, NamedTuple, Optional, Union
|
|
14
|
+
|
|
15
|
+
from duho import logging
|
|
16
|
+
|
|
17
|
+
from . import groupmembers, usermaps, utils
|
|
18
|
+
from .cache import AuthKeysCache, AuthKeysCacheBackend
|
|
19
|
+
from .config import AuthkeysConfig
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
from importlib.metadata import version as _pkg_version
|
|
23
|
+
|
|
24
|
+
__version__ = _pkg_version("authkeys")
|
|
25
|
+
except Exception: # not installed (bare source checkout)
|
|
26
|
+
__version__ = "0+unknown"
|
|
27
|
+
|
|
28
|
+
LOGGER = logging.getLogger("authkeys")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# A recognized `authorized_keys` key-type token (prefix or exact set), per the
|
|
32
|
+
# OpenSSH manual: ssh-rsa/ssh-dss/ssh-ed25519(-sk)/sk-ssh-ed25519@openssh.com/
|
|
33
|
+
# ecdsa-sha2-*/sk-ecdsa-sha2-*, and their `*-cert-v01@openssh.com` cert variants.
|
|
34
|
+
# Anything else as the first token (or any token containing `=`, i.e. an
|
|
35
|
+
# options list like `command="...",no-pty`) is an options prefix, not a type.
|
|
36
|
+
_KEY_TYPE_RE = re.compile(r"^(ssh-|ecdsa-|sk-)")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _is_options_prefix(token: str) -> bool:
|
|
40
|
+
return "=" in token or not _KEY_TYPE_RE.match(token)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AuthorizedKey(NamedTuple):
|
|
44
|
+
type: str
|
|
45
|
+
key: str
|
|
46
|
+
comment: str
|
|
47
|
+
options: str = ""
|
|
48
|
+
|
|
49
|
+
def __repr__(self) -> str:
|
|
50
|
+
base = f"{self.type} {self.key} {self.comment}" if self.comment else f"{self.type} {self.key}"
|
|
51
|
+
if self.options:
|
|
52
|
+
return f"{self.options} {base}"
|
|
53
|
+
return base
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def parse(cls, authorized_key: str, comment: "Optional[str]" = None) -> "AuthorizedKey":
|
|
57
|
+
options = ""
|
|
58
|
+
first, _, rest = authorized_key.partition(" ")
|
|
59
|
+
if first and _is_options_prefix(first):
|
|
60
|
+
options = first
|
|
61
|
+
authorized_key = rest
|
|
62
|
+
parts = authorized_key.split(" ", maxsplit=2)
|
|
63
|
+
if len(parts) < 2:
|
|
64
|
+
raise ValueError(authorized_key)
|
|
65
|
+
if len(parts) == 3 and comment is None:
|
|
66
|
+
comment = parts[2]
|
|
67
|
+
return cls(parts[0], parts[1], comment or "", options)
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def parse_all(
|
|
71
|
+
cls, keys: "Union[str, Iterable[Union[AuthorizedKey, str]], None]"
|
|
72
|
+
) -> "Iterable[AuthorizedKey]":
|
|
73
|
+
keys = keys or ""
|
|
74
|
+
if isinstance(keys, str):
|
|
75
|
+
keys = keys.splitlines()
|
|
76
|
+
for key in keys:
|
|
77
|
+
if isinstance(key, str):
|
|
78
|
+
key = key.strip()
|
|
79
|
+
if not key or key.startswith("#"):
|
|
80
|
+
continue
|
|
81
|
+
key = cls.parse(key)
|
|
82
|
+
if key:
|
|
83
|
+
yield key
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class AuthkeysSource:
|
|
87
|
+
"""Base class for key sources. Subclasses yield raw ``authorized_keys`` lines."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, conf: SectionProxy, globals: dict) -> None:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
def authorized_keys(self, username: str) -> Iterable[str]:
|
|
93
|
+
return ()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
Usermap = Callable[[str, str, "Source"], str]
|
|
97
|
+
Sanitizer = Callable[[str, str, AuthorizedKey], "Optional[AuthorizedKey]"]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class UserConfig:
|
|
101
|
+
authorized_users: "List[str]"
|
|
102
|
+
authorized_groups: "List[str]"
|
|
103
|
+
|
|
104
|
+
def __init__(self, authorized_users=None, authorized_groups=None) -> None:
|
|
105
|
+
self.authorized_users = authorized_users or []
|
|
106
|
+
self.authorized_groups = authorized_groups or []
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def default_sanitize(src: str, uid: str, key: AuthorizedKey) -> AuthorizedKey:
|
|
110
|
+
if not key.comment:
|
|
111
|
+
key = key._replace(comment=f"{uid}(src={src})")
|
|
112
|
+
return key
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class Source:
|
|
117
|
+
cached: bool
|
|
118
|
+
enabled: bool
|
|
119
|
+
backend: "Optional[AuthkeysSource]"
|
|
120
|
+
sanitize: "Optional[Sanitizer]"
|
|
121
|
+
expire: "Optional[int]" # per-source TTL override; None => use [cache] expire
|
|
122
|
+
|
|
123
|
+
@classmethod
|
|
124
|
+
def from_config(
|
|
125
|
+
cls, config: SectionProxy, globals: dict, defaults: "Source"
|
|
126
|
+
) -> "Source":
|
|
127
|
+
cached = config.getboolean("cached", defaults.cached)
|
|
128
|
+
enabled = config.getboolean("enabled", defaults.enabled)
|
|
129
|
+
backend = config.get("backend")
|
|
130
|
+
sanitize = config.get("sanitize")
|
|
131
|
+
if enabled and backend:
|
|
132
|
+
backend = utils.call(backend, config, globals)
|
|
133
|
+
if isinstance(sanitize, str):
|
|
134
|
+
sanitize = utils.import_module_object(sanitize)
|
|
135
|
+
elif sanitize is None:
|
|
136
|
+
sanitize = defaults.sanitize
|
|
137
|
+
expire = config.get("expire")
|
|
138
|
+
try:
|
|
139
|
+
expire = int(expire) if expire is not None else None
|
|
140
|
+
except (TypeError, ValueError):
|
|
141
|
+
LOGGER.error(f"Invalid source 'expire' {expire!r}; using [cache] expire")
|
|
142
|
+
expire = None
|
|
143
|
+
return cls(
|
|
144
|
+
cached=cached,
|
|
145
|
+
enabled=enabled,
|
|
146
|
+
backend=backend,
|
|
147
|
+
sanitize=sanitize,
|
|
148
|
+
expire=expire,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class AuthKeys:
|
|
153
|
+
def __init__(self) -> None:
|
|
154
|
+
self.globals: dict = {}
|
|
155
|
+
self.sources: "Dict[str, Source]" = {}
|
|
156
|
+
self.usermap: Usermap = usermaps.none
|
|
157
|
+
self.users: "Dict[str, UserConfig]" = {}
|
|
158
|
+
self.cache: "Optional[AuthKeysCache]" = None
|
|
159
|
+
self.use_expired_on_error = False
|
|
160
|
+
self.groupmembers = groupmembers.system_groupmembers
|
|
161
|
+
self.source_defaults = Source(
|
|
162
|
+
cached=True,
|
|
163
|
+
enabled=True,
|
|
164
|
+
backend=None,
|
|
165
|
+
sanitize=default_sanitize,
|
|
166
|
+
expire=None,
|
|
167
|
+
)
|
|
168
|
+
# Guards the per-(uid, source) cache miss -> compute -> store section so
|
|
169
|
+
# the threaded key server (ThreadingHTTPServer) doesn't race on the
|
|
170
|
+
# shared cache backend (esp. the file backend's read/write).
|
|
171
|
+
self._lock = threading.RLock()
|
|
172
|
+
|
|
173
|
+
def load_config(self, config: AuthkeysConfig) -> None:
|
|
174
|
+
for section in config.sections():
|
|
175
|
+
if not section.startswith("source:"):
|
|
176
|
+
continue
|
|
177
|
+
name = section[len("source:"):]
|
|
178
|
+
src_config = config[section]
|
|
179
|
+
LOGGER.info(f"Registering source {name}")
|
|
180
|
+
self.sources[name] = Source.from_config(
|
|
181
|
+
src_config, self.globals, self.source_defaults
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
self.usermap = utils.import_module_object(
|
|
185
|
+
config["globals"].get("usermap") or "authkeys.usermaps.none"
|
|
186
|
+
)
|
|
187
|
+
self.groupmembers = utils.import_module_object(
|
|
188
|
+
config["globals"].get("groupmembers")
|
|
189
|
+
or "authkeys.groupmembers.system_groupmembers"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
cache_conf = config["cache"]
|
|
193
|
+
cache_backend = utils.import_module_object(
|
|
194
|
+
cache_conf.get("backend") or "authkeys.cache.AuthKeysCacheMemBackend"
|
|
195
|
+
)
|
|
196
|
+
# Parse the TTL independently: a malformed `expire` should fall back to
|
|
197
|
+
# the default, not disable caching entirely (and not mask a genuine
|
|
198
|
+
# backend construction error below).
|
|
199
|
+
try:
|
|
200
|
+
ttl = int(cache_conf.get("expire", 300))
|
|
201
|
+
except (TypeError, ValueError):
|
|
202
|
+
LOGGER.error(
|
|
203
|
+
f"Invalid cache 'expire' value {cache_conf.get('expire')!r}; "
|
|
204
|
+
f"falling back to default TTL 300"
|
|
205
|
+
)
|
|
206
|
+
ttl = 300
|
|
207
|
+
neg_raw = cache_conf.get("negative_expire")
|
|
208
|
+
try:
|
|
209
|
+
negative_ttl = int(neg_raw) if neg_raw is not None else None
|
|
210
|
+
except (TypeError, ValueError):
|
|
211
|
+
LOGGER.error(f"Invalid 'negative_expire' {neg_raw!r}; using expire")
|
|
212
|
+
negative_ttl = None
|
|
213
|
+
try:
|
|
214
|
+
self.cache = AuthKeysCache(
|
|
215
|
+
backend=cache_backend.from_config(cache_conf),
|
|
216
|
+
ttl=ttl,
|
|
217
|
+
negative_ttl=negative_ttl,
|
|
218
|
+
)
|
|
219
|
+
except Exception as e:
|
|
220
|
+
LOGGER.error(f"Could not load cache due to\n{e}")
|
|
221
|
+
self.cache = None
|
|
222
|
+
|
|
223
|
+
self.use_expired_on_error = utils.parse_bool(
|
|
224
|
+
cache_conf.get("expired_on_error")
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def user_config_path(self, username: str):
|
|
228
|
+
"""Return a user's ``~/.ssh/authkeys.conf`` path, or None if unknown.
|
|
229
|
+
|
|
230
|
+
POSIX-only (uses ``pwd``); returns None when the user has no local
|
|
231
|
+
account, so the caller can fall back to resolving the literal username.
|
|
232
|
+
"""
|
|
233
|
+
from pathlib import Path
|
|
234
|
+
|
|
235
|
+
from . import config as _config
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
user = utils.get_user(username)
|
|
239
|
+
except Exception:
|
|
240
|
+
return None
|
|
241
|
+
return Path(user.pw_dir) / _config.USER_CONF_PATH
|
|
242
|
+
|
|
243
|
+
def resolve(
|
|
244
|
+
self, username: str, *, load_delegation: bool = True
|
|
245
|
+
) -> "List[AuthorizedKey]":
|
|
246
|
+
"""Load per-user delegation (if any) and resolve keys.
|
|
247
|
+
|
|
248
|
+
Used by both the CLI and the HTTP server so ``[authorized]``
|
|
249
|
+
user/group delegation behaves identically for each. Only the shared
|
|
250
|
+
``self.users`` mutation in ``load_user_config`` is done under the lock;
|
|
251
|
+
the resolution (which may fetch from a slow upstream) runs OUTSIDE it so
|
|
252
|
+
one hung source can't stall concurrent requests. Returns a
|
|
253
|
+
fully-materialized list.
|
|
254
|
+
"""
|
|
255
|
+
if load_delegation:
|
|
256
|
+
path = self.user_config_path(username)
|
|
257
|
+
try:
|
|
258
|
+
user_conf = AuthkeysConfig.from_config(path) if path else None
|
|
259
|
+
if user_conf is not None:
|
|
260
|
+
with self._lock:
|
|
261
|
+
self.load_user_config(username, user_conf)
|
|
262
|
+
except Exception as e:
|
|
263
|
+
LOGGER.error(
|
|
264
|
+
f"Could not load delegation config for {username}: {e}"
|
|
265
|
+
)
|
|
266
|
+
return list(self.authorized_keys(username))
|
|
267
|
+
|
|
268
|
+
def load_user_config(self, username: str, parser: AuthkeysConfig) -> None:
|
|
269
|
+
config = UserConfig()
|
|
270
|
+
authorized = parser["authorized"] if parser.has_section("authorized") else None
|
|
271
|
+
groups = authorized.getlist("groups", []) if authorized else []
|
|
272
|
+
if not authorized or "users" not in authorized:
|
|
273
|
+
config.authorized_users = [username]
|
|
274
|
+
else:
|
|
275
|
+
config.authorized_users = authorized.getlist("users", [])
|
|
276
|
+
config.authorized_groups = list(groups)
|
|
277
|
+
LOGGER.info(f"Authorized Users: {config.authorized_users}")
|
|
278
|
+
LOGGER.info(f"Authorized Groups: {groups}")
|
|
279
|
+
for groupname in groups:
|
|
280
|
+
try:
|
|
281
|
+
members = self.groupmembers(groupname, self.globals)
|
|
282
|
+
except Exception as e:
|
|
283
|
+
LOGGER.error(f"Could not resolve group '{groupname}': {e}")
|
|
284
|
+
continue
|
|
285
|
+
LOGGER.info(f"{groupname}: {members}")
|
|
286
|
+
for usr in members:
|
|
287
|
+
if usr not in config.authorized_users:
|
|
288
|
+
config.authorized_users.append(usr)
|
|
289
|
+
|
|
290
|
+
self.users[username] = config
|
|
291
|
+
|
|
292
|
+
def _parse_source_lines(
|
|
293
|
+
self, src_name: str, uid: str, raw_lines: "Iterable[str]", sanitize
|
|
294
|
+
) -> "List[AuthorizedKey]":
|
|
295
|
+
"""Parse raw source lines into keys, skipping (not aborting on) bad ones.
|
|
296
|
+
|
|
297
|
+
A single malformed line must not discard the whole source's keys, so each
|
|
298
|
+
line is parsed defensively -- a ``ValueError`` on one line is logged and
|
|
299
|
+
skipped and parsing continues.
|
|
300
|
+
"""
|
|
301
|
+
keys: "List[AuthorizedKey]" = []
|
|
302
|
+
for raw in raw_lines:
|
|
303
|
+
for line in (raw.splitlines() if isinstance(raw, str) else [raw]):
|
|
304
|
+
try:
|
|
305
|
+
parsed = list(AuthorizedKey.parse_all(line))
|
|
306
|
+
except ValueError as e:
|
|
307
|
+
LOGGER.warning(
|
|
308
|
+
f"Skipping malformed key line from {src_name} for {uid}: {e}"
|
|
309
|
+
)
|
|
310
|
+
continue
|
|
311
|
+
for key in parsed:
|
|
312
|
+
if sanitize:
|
|
313
|
+
key = sanitize(src_name, uid, key)
|
|
314
|
+
if not key:
|
|
315
|
+
continue
|
|
316
|
+
keys.append(key)
|
|
317
|
+
return keys
|
|
318
|
+
|
|
319
|
+
def _resolve_source_user(
|
|
320
|
+
self, src_name: str, src: "Source", usr: str, uid: str
|
|
321
|
+
) -> "List[AuthorizedKey]":
|
|
322
|
+
"""Resolve one source for one user, with the cache guarded by ``self._lock``.
|
|
323
|
+
|
|
324
|
+
The lock guards only the cache read and write -- NOT the upstream fetch --
|
|
325
|
+
so one slow/hung source cannot stall other users' logins (esp. concurrent
|
|
326
|
+
``serve`` requests). Two threads may fetch a cold key concurrently; that is
|
|
327
|
+
correctness-safe (last write wins). Returns a fully-materialized list; the
|
|
328
|
+
caller yields outside any lock.
|
|
329
|
+
"""
|
|
330
|
+
cache_key = (uid, src_name)
|
|
331
|
+
src_ttl = getattr(src, "expire", None)
|
|
332
|
+
if self.cache and src.cached:
|
|
333
|
+
with self._lock:
|
|
334
|
+
cached = self.cache.get(cache_key, ttl=src_ttl)
|
|
335
|
+
if cached is not None:
|
|
336
|
+
LOGGER.info(f"Using cached source: {src_name} for {usr} -> {uid}")
|
|
337
|
+
return list(AuthorizedKey.parse_all(cached))
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
LOGGER.info(f"Using source: {src_name} for {usr} -> {uid}")
|
|
341
|
+
keys = self._parse_source_lines(
|
|
342
|
+
src_name, uid, src.backend.authorized_keys(uid), src.sanitize
|
|
343
|
+
)
|
|
344
|
+
if self.cache and src.cached:
|
|
345
|
+
with self._lock:
|
|
346
|
+
self.cache[cache_key] = "\n".join(str(k) for k in keys)
|
|
347
|
+
return keys
|
|
348
|
+
except Exception as e:
|
|
349
|
+
LOGGER.error(
|
|
350
|
+
f"Error while loading keys for {usr} from {src_name}\n{e}"
|
|
351
|
+
)
|
|
352
|
+
if self.use_expired_on_error and self.cache:
|
|
353
|
+
with self._lock:
|
|
354
|
+
expired = self.cache.get(cache_key, include_expired=True)
|
|
355
|
+
if expired:
|
|
356
|
+
LOGGER.warning(
|
|
357
|
+
f"Falling back to expired keys for {src_name}: "
|
|
358
|
+
f"{usr} -> {uid}"
|
|
359
|
+
)
|
|
360
|
+
return list(AuthorizedKey.parse_all(expired))
|
|
361
|
+
return []
|
|
362
|
+
|
|
363
|
+
def authorized_keys(self, username: str) -> "Iterable[AuthorizedKey]":
|
|
364
|
+
usr_config = self.users.get(username) or UserConfig([username])
|
|
365
|
+
# Dedup on (options, type, key) -- NOT the whole tuple -- so the same
|
|
366
|
+
# public key coming from two sources (with different per-source
|
|
367
|
+
# comments) is emitted once, keeping the first comment seen. An
|
|
368
|
+
# option-bearing key (e.g. `command="...",no-pty ssh-rsa AAAA`) is
|
|
369
|
+
# kept distinct from the bare form of the same key, since the options
|
|
370
|
+
# materially change what the key is allowed to do.
|
|
371
|
+
seen: "set" = set()
|
|
372
|
+
|
|
373
|
+
for src_name, src in self.sources.items():
|
|
374
|
+
if not src.enabled or src.backend is None:
|
|
375
|
+
continue
|
|
376
|
+
for usr in usr_config.authorized_users:
|
|
377
|
+
uid = self.usermap(usr, src_name, src)
|
|
378
|
+
for key in self._resolve_source_user(src_name, src, usr, uid):
|
|
379
|
+
identity = (key.options, key.type, key.key)
|
|
380
|
+
if identity not in seen:
|
|
381
|
+
seen.add(identity)
|
|
382
|
+
yield key
|
authkeys/__main__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""``python -m authkeys`` entry point.
|
|
2
|
+
|
|
3
|
+
If no subcommand is given, ``resolve`` is assumed, so ``authkeys <user>``
|
|
4
|
+
behaves like ``authkeys resolve <user>`` — matching the console script.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .cli import run
|
|
8
|
+
|
|
9
|
+
if __name__ == "__main__":
|
|
10
|
+
raise SystemExit(run())
|
authkeys/cache.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""TTL cache for resolved authorized keys.
|
|
2
|
+
|
|
3
|
+
Entries are keyed by ``(uid, source_name)`` and store the serialized keys plus a
|
|
4
|
+
timestamp. :class:`AuthKeysCache` applies the TTL; backends only persist and
|
|
5
|
+
retrieve ``(value, timestamp)`` pairs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from time import time
|
|
10
|
+
from typing import Iterable, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
# (uid, source_name)
|
|
13
|
+
CacheKey = Tuple[str, str]
|
|
14
|
+
# (serialized_keys, stored_at_epoch)
|
|
15
|
+
CacheEntry = Tuple[str, float]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AuthKeysCacheBackend:
|
|
19
|
+
"""Persist ``(value, timestamp)`` pairs keyed by :data:`CacheKey`.
|
|
20
|
+
|
|
21
|
+
Enumeration/deletion (``keys``/``__delitem__``/``sweep``) are optional; a
|
|
22
|
+
backend that can't support them (e.g. an in-memory one across processes)
|
|
23
|
+
raises ``NotImplementedError`` so the ``authkeys cache`` subcommand can report
|
|
24
|
+
the limitation gracefully.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_config(cls, config) -> "AuthKeysCacheBackend":
|
|
29
|
+
return cls()
|
|
30
|
+
|
|
31
|
+
def __getitem__(self, key: CacheKey) -> "Optional[CacheEntry]": # pragma: no cover
|
|
32
|
+
raise NotImplementedError
|
|
33
|
+
|
|
34
|
+
def __setitem__(self, key: CacheKey, value: str) -> None: # pragma: no cover
|
|
35
|
+
raise NotImplementedError
|
|
36
|
+
|
|
37
|
+
def keys(self) -> "Iterable[CacheKey]": # pragma: no cover
|
|
38
|
+
raise NotImplementedError
|
|
39
|
+
|
|
40
|
+
def __delitem__(self, key: CacheKey) -> None: # pragma: no cover
|
|
41
|
+
raise NotImplementedError
|
|
42
|
+
|
|
43
|
+
def sweep(
|
|
44
|
+
self, *, max_age: "Optional[float]" = None, max_entries: "Optional[int]" = None
|
|
45
|
+
) -> int:
|
|
46
|
+
"""Evict expired/excess entries; return the number removed."""
|
|
47
|
+
removed = 0
|
|
48
|
+
entries = [(k, self[k]) for k in self.keys()]
|
|
49
|
+
entries = [(k, e) for k, e in entries if e]
|
|
50
|
+
if max_age is not None:
|
|
51
|
+
cutoff = time() - max_age
|
|
52
|
+
for k, e in list(entries):
|
|
53
|
+
if e[1] < cutoff:
|
|
54
|
+
del self[k]
|
|
55
|
+
removed += 1
|
|
56
|
+
entries.remove((k, e))
|
|
57
|
+
if max_entries is not None and len(entries) > max_entries:
|
|
58
|
+
# Drop the oldest first.
|
|
59
|
+
entries.sort(key=lambda ke: ke[1][1])
|
|
60
|
+
for k, _e in entries[: len(entries) - max_entries]:
|
|
61
|
+
del self[k]
|
|
62
|
+
removed += 1
|
|
63
|
+
return removed
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class AuthKeysCacheMemBackend(AuthKeysCacheBackend):
|
|
67
|
+
def __init__(self) -> None:
|
|
68
|
+
self.db: "dict[CacheKey, CacheEntry]" = {}
|
|
69
|
+
|
|
70
|
+
def __getitem__(self, key: CacheKey) -> "Optional[CacheEntry]":
|
|
71
|
+
return self.db.get(key)
|
|
72
|
+
|
|
73
|
+
def __setitem__(self, key: CacheKey, value: str) -> None:
|
|
74
|
+
self.db[key] = (value, time())
|
|
75
|
+
|
|
76
|
+
def keys(self) -> "Iterable[CacheKey]":
|
|
77
|
+
return list(self.db.keys())
|
|
78
|
+
|
|
79
|
+
def __delitem__(self, key: CacheKey) -> None:
|
|
80
|
+
self.db.pop(key, None)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class AuthKeysCacheFileBackend(AuthKeysCacheBackend):
|
|
84
|
+
"""Cache keys under ``<path>/<source>/<uid>``; mtime is the timestamp."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, path: "str | Path") -> None:
|
|
87
|
+
self.path = Path(path)
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def from_config(cls, config) -> "AuthKeysCacheFileBackend":
|
|
91
|
+
for path in config.getlist("path", []):
|
|
92
|
+
path = Path(path)
|
|
93
|
+
if path.exists():
|
|
94
|
+
return cls(path)
|
|
95
|
+
raise FileNotFoundError(
|
|
96
|
+
"No existing cache 'path' configured for the file cache backend"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def cached_entry_path(self, key: CacheKey) -> "Optional[Path]":
|
|
100
|
+
uid, src = key
|
|
101
|
+
if not (self.path and self.path.exists()):
|
|
102
|
+
return None
|
|
103
|
+
src_cache = self.path / src
|
|
104
|
+
# Cache entries reveal which principals a host trusts -> keep them private.
|
|
105
|
+
src_cache.mkdir(exist_ok=True)
|
|
106
|
+
try:
|
|
107
|
+
src_cache.chmod(0o700)
|
|
108
|
+
except OSError:
|
|
109
|
+
pass
|
|
110
|
+
return src_cache / uid
|
|
111
|
+
|
|
112
|
+
def __getitem__(self, key: CacheKey) -> "Optional[CacheEntry]":
|
|
113
|
+
cached = self.cached_entry_path(key)
|
|
114
|
+
if cached and cached.exists():
|
|
115
|
+
return cached.read_text(), cached.stat().st_mtime
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
def __setitem__(self, key: CacheKey, value: str) -> None:
|
|
119
|
+
cached = self.cached_entry_path(key)
|
|
120
|
+
if not cached:
|
|
121
|
+
return
|
|
122
|
+
# Write atomically (temp file in the same dir + os.replace) so a concurrent
|
|
123
|
+
# reader -- including a separate sshd-spawned process, which the in-process
|
|
124
|
+
# lock does not cover -- never sees a truncated entry.
|
|
125
|
+
import os
|
|
126
|
+
import tempfile
|
|
127
|
+
|
|
128
|
+
fd, tmp = tempfile.mkstemp(dir=str(cached.parent), prefix=".tmp-")
|
|
129
|
+
try:
|
|
130
|
+
os.write(fd, value.encode("utf-8"))
|
|
131
|
+
os.fsync(fd)
|
|
132
|
+
finally:
|
|
133
|
+
os.close(fd)
|
|
134
|
+
try:
|
|
135
|
+
os.chmod(tmp, 0o600)
|
|
136
|
+
except OSError:
|
|
137
|
+
pass
|
|
138
|
+
os.replace(tmp, str(cached))
|
|
139
|
+
|
|
140
|
+
def keys(self) -> "Iterable[CacheKey]":
|
|
141
|
+
if not (self.path and self.path.exists()):
|
|
142
|
+
return []
|
|
143
|
+
found = []
|
|
144
|
+
for src_dir in self.path.iterdir():
|
|
145
|
+
if src_dir.is_dir():
|
|
146
|
+
for entry in src_dir.iterdir():
|
|
147
|
+
if entry.is_file() and not entry.name.startswith(".tmp-"):
|
|
148
|
+
found.append((entry.name, src_dir.name))
|
|
149
|
+
return found
|
|
150
|
+
|
|
151
|
+
def __delitem__(self, key: CacheKey) -> None:
|
|
152
|
+
cached = self.cached_entry_path(key)
|
|
153
|
+
if cached and cached.exists():
|
|
154
|
+
cached.unlink()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# Marker distinguishing a cached "this (uid, source) genuinely has no keys"
|
|
158
|
+
# (negative hit) from a cached key set. It must never be a valid key line.
|
|
159
|
+
_NEGATIVE_MARKER = "\x00authkeys:empty"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class AuthKeysCache:
|
|
163
|
+
"""TTL cache with a distinct (shorter) TTL for negative (empty) results.
|
|
164
|
+
|
|
165
|
+
``ttl`` is the positive TTL. ``negative_ttl`` (defaults to ``ttl``) bounds a
|
|
166
|
+
cached "no keys" result so a newly-added key appears sooner. An *errored*
|
|
167
|
+
resolution is never stored here -- only genuine results -- so
|
|
168
|
+
``expired_on_error`` never resurrects an outage as "empty".
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
def __init__(
|
|
172
|
+
self,
|
|
173
|
+
backend: AuthKeysCacheBackend,
|
|
174
|
+
ttl: int = 1,
|
|
175
|
+
negative_ttl: "Optional[int]" = None,
|
|
176
|
+
) -> None:
|
|
177
|
+
self.ttl = ttl
|
|
178
|
+
self.negative_ttl = ttl if negative_ttl is None else negative_ttl
|
|
179
|
+
self.backend = backend
|
|
180
|
+
|
|
181
|
+
def _fresh(self, stored_at: float, ttl: int) -> bool:
|
|
182
|
+
return (time() - ttl) <= stored_at
|
|
183
|
+
|
|
184
|
+
def get(
|
|
185
|
+
self,
|
|
186
|
+
key: CacheKey,
|
|
187
|
+
*,
|
|
188
|
+
include_expired: bool = False,
|
|
189
|
+
ttl: "Optional[int]" = None,
|
|
190
|
+
) -> "Optional[str]":
|
|
191
|
+
result = self.backend[key]
|
|
192
|
+
if not result:
|
|
193
|
+
return None
|
|
194
|
+
value, stored_at = result
|
|
195
|
+
is_negative = value == _NEGATIVE_MARKER
|
|
196
|
+
effective = self.negative_ttl if is_negative else (
|
|
197
|
+
self.ttl if ttl is None else ttl
|
|
198
|
+
)
|
|
199
|
+
if include_expired or self._fresh(stored_at, effective):
|
|
200
|
+
return "" if is_negative else value
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
def set(self, key: CacheKey, value: str, *, negative: bool = False) -> None:
|
|
204
|
+
self.backend[key] = _NEGATIVE_MARKER if negative else value
|
|
205
|
+
|
|
206
|
+
def __getitem__(self, key: CacheKey) -> "Optional[str]":
|
|
207
|
+
return self.get(key, include_expired=False)
|
|
208
|
+
|
|
209
|
+
def __setitem__(self, key: CacheKey, value: str) -> None:
|
|
210
|
+
# Empty string => negative (known-empty) entry with the negative TTL.
|
|
211
|
+
self.set(key, value, negative=(value == ""))
|