mirror of
https://github.com/kiviktnm/decman.git
synced 2026-09-19 12:08:28 +00:00
Add user and pgp key management modules (fixes #38)
This commit is contained in:
+42
-1
@@ -14,7 +14,7 @@ Building of foreign packages happens in a chroot. This creates some overhead, bu
|
|||||||
|
|
||||||
Build packages are by default stored in a cache `/var/cache/decman/aur`. This plugin keeps 3 most recent versions of all packages.
|
Build packages are by default stored in a cache `/var/cache/decman/aur`. This plugin keeps 3 most recent versions of all packages.
|
||||||
|
|
||||||
When installing packages from other version control systems than git, you'll need to install the package for that VCS. There is an [issue and a workaround](source) related to fossil packages. Note that the issue's workaround is for an old version of decman. With this version, set the `makepkg_user` with `decman.aur.makepkg_user`.
|
When installing packages from other version control systems than git, you'll need to install the package for that VCS.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -79,6 +79,47 @@ class MyModule(decman.Module):
|
|||||||
|
|
||||||
If these sets change, this plugin will flag the module as changed. The module's `on_change` method will be executed.
|
If these sets change, this plugin will flag the module as changed. The module's `on_change` method will be executed.
|
||||||
|
|
||||||
|
## Recommended setup
|
||||||
|
|
||||||
|
I recommend setting up a build user for AUR packages. Then you can import PGP keys to that user's keyring that will be used for verifying AUR packages. The build user setup might help with some version control systems such as fossil packages.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import decman
|
||||||
|
import os
|
||||||
|
from decman.extras.gpg import GPGReceiver
|
||||||
|
from decman.extras.users import User, UserManager
|
||||||
|
|
||||||
|
um = UserManager()
|
||||||
|
gpg = GPGReceiver()
|
||||||
|
|
||||||
|
# Create builduser
|
||||||
|
um.add_user(User(
|
||||||
|
username="builduser",
|
||||||
|
home="/var/lib/builduser",
|
||||||
|
system=True,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Receive desired PGP keys to that account (Spotify as an example)
|
||||||
|
gpg.fetch_key(
|
||||||
|
user="builduser",
|
||||||
|
gpg_home="/var/lib/builduser/gnupg",
|
||||||
|
fingerprint="E1096BCBFF6D418796DE78515384CE82BA52C83A",
|
||||||
|
uri="https://download.spotify.com/debian/pubkey_5384CE82BA52C83A.gpg",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configure aur to use builduser and the GNUPGHOME.
|
||||||
|
os.environ["GNUPGHOME"] = "/var/lib/builduser/gnupg"
|
||||||
|
decman.aur.makepkg_user = "builduser"
|
||||||
|
|
||||||
|
# Add version control systems required by the packages
|
||||||
|
decman.pacman.packages |= {"fossil"}
|
||||||
|
|
||||||
|
# Add AUR packages that require PGP keys or builduser setup
|
||||||
|
decman.aur.packages |= {"spotify", "pikchr-fossil"}
|
||||||
|
|
||||||
|
decman.modules += [um, gpg]
|
||||||
|
```
|
||||||
|
|
||||||
## Keys used in the decman store
|
## Keys used in the decman store
|
||||||
|
|
||||||
- `aur_packages_for_module`
|
- `aur_packages_for_module`
|
||||||
|
|||||||
+260
@@ -0,0 +1,260 @@
|
|||||||
|
# Extras
|
||||||
|
|
||||||
|
Decman ships with some built in modules. They implement functionality that is probably useful for declarative management, but for one reason or another don't make sense as plugins.
|
||||||
|
|
||||||
|
## User and group management module
|
||||||
|
|
||||||
|
```python
|
||||||
|
import decman.extras.users
|
||||||
|
```
|
||||||
|
|
||||||
|
A decman module for managing system users, groups, and supplementary group membership and subordinate UID/GID ranges for existing users.
|
||||||
|
|
||||||
|
The module is **additive**: it only manages users/groups you explicitly register, and it only manages additional groups/subids you explicitly define. Anything created manually and not tracked by this module is left alone.
|
||||||
|
|
||||||
|
### Provided types
|
||||||
|
|
||||||
|
#### `Group`
|
||||||
|
|
||||||
|
Represents a managed group.
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Group:
|
||||||
|
groupname: str
|
||||||
|
gid: Optional[int] = None
|
||||||
|
system: bool = False
|
||||||
|
```
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
|
||||||
|
- `groupname`: Group name.
|
||||||
|
- `gid`: Desired numeric GID. If omitted, system assigns one.
|
||||||
|
- `system`: Only affects _creation_ (`groupadd --system`). Changing this after creation does nothing.
|
||||||
|
|
||||||
|
#### `User`
|
||||||
|
|
||||||
|
Represents a managed user.
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class User:
|
||||||
|
username: str
|
||||||
|
uid: Optional[int] = None
|
||||||
|
group: Optional[str] = None
|
||||||
|
home: Optional[str] = None
|
||||||
|
shell: Optional[str] = None
|
||||||
|
groups: tuple[str, ...] = ()
|
||||||
|
system: bool = False
|
||||||
|
```
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
|
||||||
|
- `username`: Login name.
|
||||||
|
- `uid`: Desired numeric UID. If omitted, system assigns one.
|
||||||
|
- `group`: Primary group name.
|
||||||
|
- `home`: Home directory.
|
||||||
|
- `shell`: Login shell.
|
||||||
|
- `groups`: Supplementary groups set.
|
||||||
|
- `system`: Only affects _creation_ (`useradd --system`). Changing this after creation does nothing.
|
||||||
|
|
||||||
|
### `UserManager` module
|
||||||
|
|
||||||
|
```python
|
||||||
|
class UserManager(Module):
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Lifecycle
|
||||||
|
|
||||||
|
- Before update
|
||||||
|
- Create/modify managed groups.
|
||||||
|
- Create/modify managed users.
|
||||||
|
- Delete previously-managed users/groups that are no longer listed.
|
||||||
|
- After update
|
||||||
|
- Apply **additional** supplementary group membership and **subuid/subgid** ranges (including removals).
|
||||||
|
|
||||||
|
#### Store keys
|
||||||
|
|
||||||
|
The module persists state in decman store under these keys:
|
||||||
|
|
||||||
|
- `usermanager_users`
|
||||||
|
- `usermanager_groups`
|
||||||
|
- `usermanager_user_additional_groups`
|
||||||
|
- `usermanager_user_subuids`
|
||||||
|
- `usermanager_user_subgids`
|
||||||
|
|
||||||
|
The module does **not** parse `/etc/subuid` or `/etc/subgid`; it relies on these store keys to compute additions/removals.
|
||||||
|
|
||||||
|
#### Methods
|
||||||
|
|
||||||
|
##### `add_user(user: User)`
|
||||||
|
|
||||||
|
Ensure a user exists with the configured attributes.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- If `uid` is provided and an existing user matches by UID but has a different name, the module will rename the user (`usermod --login`) and apply other changes.
|
||||||
|
|
||||||
|
##### `add_group(group: Group)`
|
||||||
|
|
||||||
|
Ensure a group exists with the configured attributes.
|
||||||
|
|
||||||
|
##### `add_user_to_group(user: str, group: str)`
|
||||||
|
|
||||||
|
Ensure `user` is a member of `group`.
|
||||||
|
|
||||||
|
- This is applied in `after_update`.
|
||||||
|
- Both `user` and `group` are expected to exist
|
||||||
|
|
||||||
|
You should not use this method for users added with `add_user`.
|
||||||
|
|
||||||
|
##### `add_subuids(user: str, first: int, last: int)`
|
||||||
|
|
||||||
|
Ensure subordinate UID range `first-last` is present for `user`.
|
||||||
|
|
||||||
|
##### `add_subgids(user: str, first: int, last: int)`
|
||||||
|
|
||||||
|
Ensure subordinate GID range `first-last` is present for `user`.
|
||||||
|
|
||||||
|
### Example usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
from decman.extras.users import UserManager, User, Group
|
||||||
|
|
||||||
|
um = UserManager()
|
||||||
|
|
||||||
|
um.add_group(Group("containers", system=True))
|
||||||
|
um.add_user(User(
|
||||||
|
username="alice",
|
||||||
|
uid=1001,
|
||||||
|
group="users",
|
||||||
|
home="/home/alice",
|
||||||
|
groups=(),
|
||||||
|
shell="/bin/zsh",
|
||||||
|
))
|
||||||
|
|
||||||
|
um.add_user_to_group("bob", "containers")
|
||||||
|
|
||||||
|
um.add_subuids("alice", 100000, 165535)
|
||||||
|
um.add_subgids("alice", 100000, 165535)
|
||||||
|
|
||||||
|
import decman
|
||||||
|
decman.modules += [um]
|
||||||
|
```
|
||||||
|
|
||||||
|
## GPG receiver module
|
||||||
|
|
||||||
|
```python
|
||||||
|
import decman.extras.gpg
|
||||||
|
```
|
||||||
|
|
||||||
|
Manages importing OpenPGP public keys into per-user GnuPG homes. Tracks imported keys in the decman store and removes keys that were previously managed but are no longer configured.
|
||||||
|
|
||||||
|
This module is intentionally limited since it's main usage is for AUR build users. You probably shouldn't manage your primary user’s keyring with it.
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
#### `OwnerTrust`
|
||||||
|
|
||||||
|
Valid ownertrust levels:
|
||||||
|
|
||||||
|
- `never`
|
||||||
|
- `marginal`
|
||||||
|
- `full`
|
||||||
|
- `ultimate`
|
||||||
|
|
||||||
|
These map to GnuPG `--import-ownertrust` numeric levels `1..4`.
|
||||||
|
|
||||||
|
#### `SourceKind`
|
||||||
|
|
||||||
|
How a key is imported:
|
||||||
|
|
||||||
|
- `fingerprint`: fetch from keyserver via `--recv-keys`
|
||||||
|
- `uri`: fetch from URI via `--fetch-key`
|
||||||
|
- `file`: import from local file via `--import`
|
||||||
|
|
||||||
|
#### `Key`
|
||||||
|
|
||||||
|
Represents one managed key entry.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
|
||||||
|
- `fingerprint`: OpenPGP fingerprint, validated to be exactly 40 hex chars (spaces allowed in input; normalized by removing spaces and uppercasing).
|
||||||
|
- `source_kind`: one of `fingerprint | uri | file`.
|
||||||
|
- `source`: keyserver (for `fingerprint`), URI (for `uri`), or filepath (for `file`).
|
||||||
|
- `trust`: optional `OwnerTrust` to set via ownertrust import.
|
||||||
|
|
||||||
|
Validation behavior:
|
||||||
|
|
||||||
|
- Fingerprint is normalized: `replace(" ", "").upper()`.
|
||||||
|
- Fingerprint must match `^[0-9A-F]{40}$`; otherwise `ValueError`.
|
||||||
|
|
||||||
|
### `GPGReceiver` module
|
||||||
|
|
||||||
|
```python
|
||||||
|
class GPGReceiver(module.Module):
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Store keys
|
||||||
|
|
||||||
|
The module persists state in decman store under these keys:
|
||||||
|
|
||||||
|
- `gpgreceiver_userhome_keys`
|
||||||
|
|
||||||
|
It relies on the store to keep track which keys were added by it.
|
||||||
|
|
||||||
|
#### Public API
|
||||||
|
|
||||||
|
##### `receive_key(user: str, gpg_home: str, fingerprint: str, keyserver: str, trust: OwnerTrust | None = None)`
|
||||||
|
|
||||||
|
Receives a key with a `fingerprint` from a `keyserver` to a `gpg_home` owned by `user`.
|
||||||
|
|
||||||
|
If `trust` is provided, ownertrust is set after import.
|
||||||
|
|
||||||
|
##### `fetch_key(user: str, gpg_home: str, fingerprint: str, uri: str, trust: OwnerTrust | None=None)`
|
||||||
|
|
||||||
|
Receives a key with a `fingerprint` from a `uri` to a `gpg_home` owned by `user`.
|
||||||
|
|
||||||
|
If `trust` is provided, ownertrust is set after import.
|
||||||
|
|
||||||
|
##### `import_key(user: str, gpg_home: str, fingerprint: str, file: str, trust: OwnerTrust | None =None)`
|
||||||
|
|
||||||
|
Receives a key with a `fingerprint` from a local `file` to a `gpg_home` owned by `user`.
|
||||||
|
|
||||||
|
If `trust` is provided, ownertrust is set after import.
|
||||||
|
|
||||||
|
### Example usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
from decman.modules.gpg import GPGReceiver
|
||||||
|
import decman
|
||||||
|
|
||||||
|
gpg = GPGReceiver()
|
||||||
|
|
||||||
|
# Receive a key from a keyserver
|
||||||
|
gpg.receive_key(
|
||||||
|
user="builduser",
|
||||||
|
gpg_home="/var/lib/builduser/gnupg",
|
||||||
|
fingerprint="AAAA AAAA AAAA AAAA AAAA AAAA AAAA AAAA AAAA AAAA",
|
||||||
|
keyserver="hkps://keyserver.ubuntu.com",
|
||||||
|
trust="marginal",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch a key from a URI
|
||||||
|
gpg.fetch_key(
|
||||||
|
user="alice",
|
||||||
|
gpg_home="/home/alice/.gnupg",
|
||||||
|
fingerprint="BBBB BBBB BBBB BBBB BBBB BBBB BBBB BBBB BBBB BBBB",
|
||||||
|
uri="https://example.org/signing-key.asc",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Import a key from a local file
|
||||||
|
gpg.import_key(
|
||||||
|
user="bob",
|
||||||
|
gpg_home="/home/bob/.gnupg",
|
||||||
|
fingerprint="CCCC CCCC CCCC CCCC CCCC CCCC CCCC CCCC CCCC CCCC",
|
||||||
|
file="/etc/decman/keys/custom.asc",
|
||||||
|
)
|
||||||
|
|
||||||
|
decman.modules += [gpg]
|
||||||
|
```
|
||||||
@@ -147,7 +147,10 @@ class AurPacmanInterface(pacman.PacmanInterface):
|
|||||||
"""
|
"""
|
||||||
Returns True if a package can be installed using pacman.
|
Returns True if a package can be installed using pacman.
|
||||||
"""
|
"""
|
||||||
return pkg in self._name_index or pacman.strip_dependency(pkg) in self._provides_index
|
return (
|
||||||
|
pacman.strip_dependency(pkg) in self._name_index
|
||||||
|
or pacman.strip_dependency(pkg) in self._provides_index
|
||||||
|
)
|
||||||
|
|
||||||
def get_versioned_foreign_packages(self) -> list[tuple[str, str]]:
|
def get_versioned_foreign_packages(self) -> list[tuple[str, str]]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ class CommandFailedError(Exception):
|
|||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
command (list[str]): The command that caused the exception.
|
command (list[str]): The command that caused the exception.
|
||||||
|
output (str|None): Output of the command.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, command: list[str], output: str | None) -> None:
|
def __init__(self, command: list[str], output: str | None) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
import os
|
||||||
|
import pwd
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
import decman
|
||||||
|
import decman.core.module as module
|
||||||
|
import decman.core.output as output
|
||||||
|
import decman.core.store as _store
|
||||||
|
from decman.core.error import CommandFailedError
|
||||||
|
|
||||||
|
OwnerTrust = Literal["never", "marginal", "full", "ultimate"]
|
||||||
|
SourceKind = Literal["fingerprint", "uri", "file"]
|
||||||
|
|
||||||
|
|
||||||
|
_TRUST_MAP = {
|
||||||
|
"never": "1",
|
||||||
|
"marginal": "2",
|
||||||
|
"full": "3",
|
||||||
|
"ultimate": "4",
|
||||||
|
}
|
||||||
|
|
||||||
|
_FPR_RE = re.compile(r"^[0-9A-F]{40}$")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Key:
|
||||||
|
fingerprint: str
|
||||||
|
source_kind: SourceKind
|
||||||
|
source: str # keyserver / uri / filepath
|
||||||
|
trust: Optional[OwnerTrust] = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
fpr = self.fingerprint.replace(" ", "").upper()
|
||||||
|
if not _FPR_RE.fullmatch(fpr):
|
||||||
|
raise ValueError(f"invalid OpenPGP fingerprint: {fpr}")
|
||||||
|
object.__setattr__(self, "fingerprint", fpr)
|
||||||
|
|
||||||
|
|
||||||
|
class _GPGInterface:
|
||||||
|
def __init__(self, user: str, home: str):
|
||||||
|
self.user = user
|
||||||
|
self.home = home
|
||||||
|
|
||||||
|
def ensure_home(self) -> bool:
|
||||||
|
"""
|
||||||
|
Returns True on succees. Returns False if the user doesn't exist.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def create_missing_dirs(dirct: str, uid: int, gid: int):
|
||||||
|
dirct = os.path.normpath(dirct)
|
||||||
|
if not os.path.isdir(dirct):
|
||||||
|
parent_dir = os.path.dirname(dirct)
|
||||||
|
if not os.path.isdir(parent_dir):
|
||||||
|
create_missing_dirs(parent_dir, uid, gid)
|
||||||
|
|
||||||
|
os.mkdir(dirct)
|
||||||
|
os.chown(dirct, uid, gid)
|
||||||
|
os.chmod(dirct, 0o700)
|
||||||
|
|
||||||
|
try:
|
||||||
|
u = pwd.getpwnam(self.user)
|
||||||
|
create_missing_dirs(self.home, u.pw_uid, u.pw_gid)
|
||||||
|
return True
|
||||||
|
except OSError as error:
|
||||||
|
raise decman.SourceError(
|
||||||
|
f"Failed to create GPG directory {self.home} for {self.user}."
|
||||||
|
) from error
|
||||||
|
except KeyError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def list_fingerprints(self) -> set[str]:
|
||||||
|
out = decman.prg(
|
||||||
|
["gpg", "--homedir", self.home, "--batch", "--no-tty", "--with-colons", "--list-keys"],
|
||||||
|
user=self.user,
|
||||||
|
pty=False,
|
||||||
|
)
|
||||||
|
fprs: set[str] = set()
|
||||||
|
for line in out.splitlines():
|
||||||
|
if line.startswith("fpr:"):
|
||||||
|
parts = line.split(":")
|
||||||
|
if len(parts) > 9 and parts[9]:
|
||||||
|
fprs.add(parts[9])
|
||||||
|
return fprs
|
||||||
|
|
||||||
|
def set_key_trust(self, keys: list[tuple[str, OwnerTrust]]):
|
||||||
|
if not keys:
|
||||||
|
return
|
||||||
|
lines = [f"{fpr}:{_TRUST_MAP[trust]}:" for fpr, trust in keys]
|
||||||
|
data = "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"gpg",
|
||||||
|
"--homedir",
|
||||||
|
self.home,
|
||||||
|
"--batch",
|
||||||
|
"--yes",
|
||||||
|
"--no-tty",
|
||||||
|
"--import-ownertrust",
|
||||||
|
]
|
||||||
|
p = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
input=data,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
user=self.user,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if p.returncode != 0:
|
||||||
|
raise CommandFailedError(cmd, p.stdout)
|
||||||
|
|
||||||
|
def delete_keys(self, fingerprints: list[str]):
|
||||||
|
decman.prg(
|
||||||
|
[
|
||||||
|
"gpg",
|
||||||
|
"--homedir",
|
||||||
|
self.home,
|
||||||
|
"--batch",
|
||||||
|
"--yes",
|
||||||
|
"--no-tty",
|
||||||
|
"--delete-keys",
|
||||||
|
]
|
||||||
|
+ fingerprints,
|
||||||
|
user=self.user,
|
||||||
|
pty=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def fetch_key(self, uri: str):
|
||||||
|
decman.prg(
|
||||||
|
[
|
||||||
|
"gpg",
|
||||||
|
"--homedir",
|
||||||
|
self.home,
|
||||||
|
"--batch",
|
||||||
|
"--yes",
|
||||||
|
"--no-tty",
|
||||||
|
"--fetch-key",
|
||||||
|
uri,
|
||||||
|
],
|
||||||
|
user=self.user,
|
||||||
|
pty=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def import_key(self, path: str):
|
||||||
|
decman.prg(
|
||||||
|
[
|
||||||
|
"gpg",
|
||||||
|
"--homedir",
|
||||||
|
self.home,
|
||||||
|
"--batch",
|
||||||
|
"--yes",
|
||||||
|
"--no-tty",
|
||||||
|
"--import",
|
||||||
|
path,
|
||||||
|
],
|
||||||
|
user=self.user,
|
||||||
|
pty=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def receive_key(self, fingerprint: str, keyserver: str):
|
||||||
|
decman.prg(
|
||||||
|
[
|
||||||
|
"gpg",
|
||||||
|
"--homedir",
|
||||||
|
self.home,
|
||||||
|
"--batch",
|
||||||
|
"--yes",
|
||||||
|
"--no-tty",
|
||||||
|
"--keyserver",
|
||||||
|
keyserver,
|
||||||
|
"--recv-keys",
|
||||||
|
fingerprint,
|
||||||
|
],
|
||||||
|
user=self.user,
|
||||||
|
pty=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GPGReceiver(module.Module):
|
||||||
|
"""
|
||||||
|
Module for receiving OpenPGP keys.
|
||||||
|
|
||||||
|
This is basically built for importing AUR package keys.
|
||||||
|
|
||||||
|
If trying to add a key to an user that doesn't exist, this module silently skips user.
|
||||||
|
|
||||||
|
It's functionality is limited and I don't recommend using this with your main user account.
|
||||||
|
Instead create specific account for AUR package building and import keys to that account.
|
||||||
|
|
||||||
|
This module doesn't use the GPGME library and instead just calls gpg directly. It's simpler and
|
||||||
|
good enough for this usecase.
|
||||||
|
|
||||||
|
This module is a singleton, meaning that you should create only a one instance of this module
|
||||||
|
and pass that around.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__("gpgreceiver")
|
||||||
|
self._keys: dict[tuple[str, str], list[Key]] = {}
|
||||||
|
|
||||||
|
def receive_key(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
gpg_home: str,
|
||||||
|
fingerprint: str,
|
||||||
|
keyserver: str,
|
||||||
|
trust: OwnerTrust | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Receives a key.
|
||||||
|
|
||||||
|
The key is imported as the given ``user`` into the specified ``gpg_home``.
|
||||||
|
|
||||||
|
If trust is specified, sets it.
|
||||||
|
"""
|
||||||
|
self._keys.setdefault((user, gpg_home), []).append(
|
||||||
|
Key(fingerprint, "fingerprint", keyserver, trust)
|
||||||
|
)
|
||||||
|
|
||||||
|
def fetch_key(
|
||||||
|
self, user: str, gpg_home: str, fingerprint: str, uri: str, trust: OwnerTrust | None = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Fetches a key from a URI.
|
||||||
|
|
||||||
|
The key is imported as the given ``user`` into the specified ``gpg_home``.
|
||||||
|
|
||||||
|
If trust is specified, sets it.
|
||||||
|
"""
|
||||||
|
self._keys.setdefault((user, gpg_home), []).append(Key(fingerprint, "uri", uri, trust))
|
||||||
|
|
||||||
|
def import_key(
|
||||||
|
self, user: str, gpg_home: str, fingerprint: str, file: str, trust: OwnerTrust | None = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Imports a key from file.
|
||||||
|
|
||||||
|
The key is imported as the given ``user`` into the specified ``gpg_home``.
|
||||||
|
|
||||||
|
If trust is specified, sets it.
|
||||||
|
"""
|
||||||
|
self._keys.setdefault((user, gpg_home), []).append(Key(fingerprint, "file", file, trust))
|
||||||
|
|
||||||
|
def _add_key(self, gpg: _GPGInterface, key: Key):
|
||||||
|
match key.source_kind:
|
||||||
|
case "fingerprint":
|
||||||
|
gpg.receive_key(key.fingerprint, key.source)
|
||||||
|
case "uri":
|
||||||
|
gpg.fetch_key(key.source)
|
||||||
|
case "file":
|
||||||
|
gpg.import_key(key.source)
|
||||||
|
|
||||||
|
def before_update(self, store: _store.Store):
|
||||||
|
store.ensure("gpgreceiver_userhome_keys", {})
|
||||||
|
|
||||||
|
known_users = {
|
||||||
|
(line.split(":", 1)[0], line.split(":", 1)[1])
|
||||||
|
for line in store["gpgreceiver_userhome_keys"]
|
||||||
|
}
|
||||||
|
|
||||||
|
for user, gpg_home in self._keys.keys() | known_users:
|
||||||
|
keys = self._keys.get((user, gpg_home), [])
|
||||||
|
gpg = _GPGInterface(user, gpg_home)
|
||||||
|
|
||||||
|
if not gpg.ensure_home():
|
||||||
|
output.print_warning(f"User {user} doesn't exist, so PGP keys cannot be modified.")
|
||||||
|
del store["gpgreceiver_userhome_keys"][f"{user}:{gpg_home}"]
|
||||||
|
continue
|
||||||
|
|
||||||
|
old_fprs = store["gpgreceiver_userhome_keys"].get(f"{user}:{gpg_home}", set())
|
||||||
|
fprs_before_import = gpg.list_fingerprints()
|
||||||
|
new_fprs = set()
|
||||||
|
managed_fprs = set()
|
||||||
|
key_trust_levels = []
|
||||||
|
|
||||||
|
for key in keys:
|
||||||
|
managed_fprs.add(key.fingerprint)
|
||||||
|
if key.trust:
|
||||||
|
key_trust_levels.append((key.fingerprint, key.trust))
|
||||||
|
if key.fingerprint not in fprs_before_import:
|
||||||
|
output.print_info(
|
||||||
|
f"Adding PGP key {key.fingerprint} to {user}:{gpg_home} "
|
||||||
|
f"from {key.source_kind} {key.source}."
|
||||||
|
)
|
||||||
|
self._add_key(gpg, key)
|
||||||
|
new_fprs.add(key.fingerprint)
|
||||||
|
|
||||||
|
fprs_after_import = gpg.list_fingerprints()
|
||||||
|
missing = new_fprs - fprs_after_import
|
||||||
|
if missing:
|
||||||
|
raise decman.SourceError(
|
||||||
|
f"Fingerprints for PGP not found after importing all keys: {' '.join(missing)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if key_trust_levels:
|
||||||
|
gpg.set_key_trust(key_trust_levels)
|
||||||
|
|
||||||
|
unaccounted_fprs = (fprs_after_import - fprs_before_import) - new_fprs
|
||||||
|
if unaccounted_fprs:
|
||||||
|
output.print_warning(
|
||||||
|
"While adding PGP keys these fingerprints were unaccounted for: "
|
||||||
|
f"{' '.join(unaccounted_fprs)}"
|
||||||
|
)
|
||||||
|
output.print_warning("The keys were added, but their ownertrust was not set.")
|
||||||
|
|
||||||
|
fprs_to_remove = list(old_fprs - managed_fprs)
|
||||||
|
if fprs_to_remove:
|
||||||
|
output.print_list(
|
||||||
|
f"Deleting PGP keys from {user}:{gpg_home}", fprs_to_remove, level=output.INFO
|
||||||
|
)
|
||||||
|
gpg.delete_keys(fprs_to_remove)
|
||||||
|
|
||||||
|
store["gpgreceiver_userhome_keys"][f"{user}:{gpg_home}"] = managed_fprs
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
import grp
|
||||||
|
import pwd
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import decman.core.command as command
|
||||||
|
import decman.core.module as module
|
||||||
|
import decman.core.output as output
|
||||||
|
import decman.core.store as _store
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Group:
|
||||||
|
"""
|
||||||
|
Represents a group managed by the ``UserManager`` module.
|
||||||
|
|
||||||
|
The ``system`` attribute only affects the creation of this group.
|
||||||
|
After the group has been created, changing the ``system`` attribute does nothing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
groupname: str
|
||||||
|
gid: Optional[int] = None
|
||||||
|
system: bool = False
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
parts = []
|
||||||
|
if self.gid is not None:
|
||||||
|
parts.append(f"gid={self.gid}")
|
||||||
|
if self.system:
|
||||||
|
parts.append("system")
|
||||||
|
return f"{self.groupname}({', '.join(parts)})"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class User:
|
||||||
|
"""
|
||||||
|
Represents a user managed by the ``UserManager`` module.
|
||||||
|
|
||||||
|
The ``system`` attribute only affects the creation of this user.
|
||||||
|
After the user has been created, changing the ``system`` attribute does nothing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
username: str
|
||||||
|
uid: Optional[int] = None
|
||||||
|
group: Optional[str] = None
|
||||||
|
home: Optional[str] = None
|
||||||
|
shell: Optional[str] = None
|
||||||
|
groups: tuple[str, ...] = ()
|
||||||
|
system: bool = False
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
parts = []
|
||||||
|
if self.uid is not None:
|
||||||
|
parts.append(f"uid={self.uid}")
|
||||||
|
if self.group is not None:
|
||||||
|
parts.append(f"gid={self.group}")
|
||||||
|
if self.home is not None:
|
||||||
|
parts.append(f"home={self.home}")
|
||||||
|
if self.shell is not None:
|
||||||
|
parts.append(f"shell={self.shell}")
|
||||||
|
if self.groups:
|
||||||
|
parts.append(f"groups={','.join(self.groups)}")
|
||||||
|
if self.system:
|
||||||
|
parts.append("system")
|
||||||
|
return f"{self.username}({', '.join(parts)})"
|
||||||
|
|
||||||
|
|
||||||
|
class UserManager(module.Module):
|
||||||
|
"""
|
||||||
|
A module for managing users and groups. This module is additive, if you create a user or a group
|
||||||
|
manually, this module will not modify them, unless you explicitly add them to this module.
|
||||||
|
|
||||||
|
Users and groups are created, modified and deleted at ``before_update`` -stage.
|
||||||
|
Users are added to groups and subuids/subgids at ``after_update`` -stage.
|
||||||
|
|
||||||
|
Decman store keys used by this module are:
|
||||||
|
|
||||||
|
- ``usermanager_users``
|
||||||
|
- ``usermanager_groups``
|
||||||
|
- ``usermanager_user_additional_groups``
|
||||||
|
- ``usermanager_user_subuids``
|
||||||
|
- ``usermanager_user_subgids``
|
||||||
|
|
||||||
|
Most management done by this module is with the commands ``useradd``, ``groupadd`` and
|
||||||
|
``usermod``.
|
||||||
|
|
||||||
|
This module contains useful utilities for the most common user management cases,
|
||||||
|
but it is not complete.
|
||||||
|
If you need advanced user management features you should probably fork this module.
|
||||||
|
|
||||||
|
This module is a singleton, meaning that you should create only a one instance of this module
|
||||||
|
and pass that around.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__("usermanager")
|
||||||
|
self.users: set[User] = set()
|
||||||
|
self.groups: set[Group] = set()
|
||||||
|
self._user_additional_groups: dict[str, set[str]] = {}
|
||||||
|
self._user_subuids: dict[str, set[tuple[int, int]]] = {}
|
||||||
|
self._user_subgids: dict[str, set[tuple[int, int]]] = {}
|
||||||
|
|
||||||
|
def add_user(self, user: User):
|
||||||
|
"""
|
||||||
|
Ensures that the user exists with the given attributes.
|
||||||
|
"""
|
||||||
|
self.users.add(user)
|
||||||
|
|
||||||
|
def add_group(self, group: Group):
|
||||||
|
"""
|
||||||
|
Ensures that the group exists with the given attributes.
|
||||||
|
"""
|
||||||
|
self.groups.add(group)
|
||||||
|
|
||||||
|
def add_user_to_group(self, user: str, group: str):
|
||||||
|
"""
|
||||||
|
Ensures that the user is a member of the given group.
|
||||||
|
|
||||||
|
Both ``user`` and ``group`` should exist.
|
||||||
|
"""
|
||||||
|
self._user_additional_groups.setdefault(user, set()).add(group)
|
||||||
|
|
||||||
|
def add_subuids(self, user: str, first: int, last: int):
|
||||||
|
"""
|
||||||
|
Adds the range ``first``-``last`` subordinate uids to the ``user``s account.
|
||||||
|
|
||||||
|
Note!
|
||||||
|
|
||||||
|
This module doesn't parse ``/etc/subuid`` or ``/etc/subgid``.
|
||||||
|
Instead, the added subuids and subgids are stored in the decman store.
|
||||||
|
Stored values are used to remove the added subuids and subgids from the user.
|
||||||
|
|
||||||
|
Manual modifications or clearing the decman store can cause unexpected issues.
|
||||||
|
"""
|
||||||
|
self._user_subuids.setdefault(user, set()).add((first, last))
|
||||||
|
|
||||||
|
def add_subgids(self, user: str, first: int, last: int):
|
||||||
|
"""
|
||||||
|
Adds the range ``first``-``last`` subordinate gids to the ``user``s account.
|
||||||
|
|
||||||
|
Note!
|
||||||
|
|
||||||
|
This module doesn't parse ``/etc/subuid`` or ``/etc/subgid``.
|
||||||
|
Instead, the added subuids and subgids are stored in the decman store.
|
||||||
|
Stored values are used to remove the added subuids and subgids from the user.
|
||||||
|
|
||||||
|
Manual modifications or clearing the decman store can cause unexpected issues.
|
||||||
|
"""
|
||||||
|
self._user_subgids.setdefault(user, set()).add((first, last))
|
||||||
|
|
||||||
|
def _check_user(self, user: User, user_groups_index: dict[str, set[str]]):
|
||||||
|
userdb_name = None
|
||||||
|
userdb_uid = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
userdb_name = pwd.getpwnam(user.username)
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
if user.uid is not None:
|
||||||
|
userdb_uid = pwd.getpwuid(user.uid)
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Prioritize uid match. If uid matches but name doesn't, rename the user.
|
||||||
|
userdb = userdb_uid or userdb_name
|
||||||
|
|
||||||
|
if not userdb:
|
||||||
|
self._add_user(user)
|
||||||
|
else:
|
||||||
|
self._ensure_user_matches(user, userdb, user_groups_index)
|
||||||
|
|
||||||
|
def _add_user(self, user: User):
|
||||||
|
cmd = ["useradd"]
|
||||||
|
|
||||||
|
if user.uid is not None:
|
||||||
|
cmd += ["--uid", str(user.uid)]
|
||||||
|
|
||||||
|
if user.group:
|
||||||
|
cmd += ["--gid", user.group]
|
||||||
|
|
||||||
|
if user.home:
|
||||||
|
cmd += ["--create-home", "--home-dir", user.home]
|
||||||
|
|
||||||
|
if user.shell:
|
||||||
|
cmd += ["--shell", user.shell]
|
||||||
|
|
||||||
|
if user.groups:
|
||||||
|
cmd += ["--groups", ",".join(list(user.groups))]
|
||||||
|
|
||||||
|
if user.system:
|
||||||
|
cmd.append("--system")
|
||||||
|
|
||||||
|
cmd.append(user.username)
|
||||||
|
output.print_info(f"Creating user {user}.")
|
||||||
|
command.prg(cmd, pty=False)
|
||||||
|
|
||||||
|
def _ensure_user_matches(
|
||||||
|
self, user: User, userdb: pwd.struct_passwd, user_groups_index: dict[str, set[str]]
|
||||||
|
):
|
||||||
|
cmd = ["usermod"]
|
||||||
|
|
||||||
|
if user.username != userdb.pw_name:
|
||||||
|
cmd += ["--login", user.username]
|
||||||
|
|
||||||
|
if user.uid is not None and user.uid != userdb.pw_uid:
|
||||||
|
cmd += ["--uid", str(user.uid)]
|
||||||
|
|
||||||
|
if user.group and user.group != grp.getgrgid(userdb.pw_gid).gr_name:
|
||||||
|
cmd += ["--gid", user.group]
|
||||||
|
|
||||||
|
if user.home and user.home != userdb.pw_dir:
|
||||||
|
cmd += ["--move-home", "--home", user.home]
|
||||||
|
|
||||||
|
if user.shell and user.shell != userdb.pw_shell:
|
||||||
|
cmd += ["--shell", user.shell]
|
||||||
|
|
||||||
|
# Use old name to support renames, post rename groups match
|
||||||
|
old_groups = user_groups_index.get(userdb.pw_name, set())
|
||||||
|
if user.groups is not None and set(user.groups) != old_groups:
|
||||||
|
if user.groups:
|
||||||
|
cmd += ["--groups", ",".join(list(user.groups))]
|
||||||
|
elif old_groups:
|
||||||
|
# Remove user from other groups
|
||||||
|
cmd += ["-r", "--groups", ",".join(old_groups)]
|
||||||
|
|
||||||
|
if len(cmd) > 1:
|
||||||
|
# Use old name to support renames
|
||||||
|
cmd.append(userdb.pw_name)
|
||||||
|
output.print_info(f"Modifying user {user}.")
|
||||||
|
command.prg(cmd, pty=False)
|
||||||
|
|
||||||
|
def _user_groups_index(self) -> dict[str, set[str]]:
|
||||||
|
result: dict[str, set[str]] = {}
|
||||||
|
for gr in grp.getgrall():
|
||||||
|
group = gr.gr_name
|
||||||
|
for user in gr.gr_mem:
|
||||||
|
result.setdefault(user, set()).add(group)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _check_group(self, group: Group):
|
||||||
|
groupdb_name = None
|
||||||
|
groupdb_gid = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
groupdb_name = grp.getgrnam(group.groupname)
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
if group.gid is not None:
|
||||||
|
groupdb_gid = grp.getgrgid(group.gid)
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
groupdb = groupdb_gid or groupdb_name
|
||||||
|
|
||||||
|
if not groupdb:
|
||||||
|
self._add_group(group)
|
||||||
|
else:
|
||||||
|
self._ensure_group_matches(group, groupdb)
|
||||||
|
|
||||||
|
def _add_group(self, group: Group):
|
||||||
|
cmd = ["groupadd"]
|
||||||
|
|
||||||
|
if group.gid is not None:
|
||||||
|
cmd += ["--gid", str(group.gid)]
|
||||||
|
|
||||||
|
if group.system:
|
||||||
|
cmd.append("--system")
|
||||||
|
|
||||||
|
cmd.append(group.groupname)
|
||||||
|
output.print_info(f"Creating group {group}.")
|
||||||
|
command.prg(cmd, pty=False)
|
||||||
|
|
||||||
|
def _ensure_group_matches(self, group: Group, groupdb: grp.struct_group):
|
||||||
|
cmd = ["groupmod"]
|
||||||
|
|
||||||
|
if group.groupname != groupdb.gr_name:
|
||||||
|
cmd += ["--new-name", group.groupname]
|
||||||
|
|
||||||
|
if group.gid is not None and group.gid != groupdb.gr_gid:
|
||||||
|
cmd += ["--gid", str(group.gid)]
|
||||||
|
|
||||||
|
if len(cmd) > 1:
|
||||||
|
# Use old name to support renames
|
||||||
|
cmd.append(groupdb.gr_name)
|
||||||
|
output.print_info(f"Modifying group {group}.")
|
||||||
|
command.prg(cmd, pty=False)
|
||||||
|
|
||||||
|
def _modify_user_groups_subids(self, user: str, store: _store.Store):
|
||||||
|
store.ensure("usermanager_user_additional_groups", {})
|
||||||
|
store.ensure("usermanager_user_subuids", {})
|
||||||
|
store.ensure("usermanager_user_subgids", {})
|
||||||
|
|
||||||
|
old_groups = store["usermanager_user_additional_groups"].get(user, set())
|
||||||
|
old_subuids = store["usermanager_user_subuids"].get(user, set())
|
||||||
|
old_subgids = store["usermanager_user_subgids"].get(user, set())
|
||||||
|
|
||||||
|
new_groups = self._user_additional_groups.get(user, set())
|
||||||
|
new_subuids = self._user_subuids.get(user, set())
|
||||||
|
new_subgids = self._user_subgids.get(user, set())
|
||||||
|
|
||||||
|
groups_to_remove = old_groups - new_groups
|
||||||
|
groups_to_add = new_groups - old_groups
|
||||||
|
subuids_to_remove = old_subuids - new_subuids
|
||||||
|
subuids_to_add = new_subuids - old_subuids
|
||||||
|
subgids_to_remove = old_subgids - new_subgids
|
||||||
|
subgids_to_add = new_subgids - old_subgids
|
||||||
|
|
||||||
|
output.print_list(
|
||||||
|
f"Removing {user} from groups:", list(groups_to_remove), level=output.INFO
|
||||||
|
)
|
||||||
|
output.print_list(f"Adding {user} to groups:", list(groups_to_add), level=output.INFO)
|
||||||
|
|
||||||
|
# It's not possible to remove and add groups at the same time, so remove groups first
|
||||||
|
if groups_to_remove:
|
||||||
|
command.prg(["usermod", "-r", "-G", ",".join(groups_to_remove), user], pty=False)
|
||||||
|
# Set these only if things change, no need to clutter the store otherwise
|
||||||
|
store["usermanager_user_additional_groups"][user] = new_groups
|
||||||
|
|
||||||
|
# Rest of the changes can be done with a single command
|
||||||
|
cmd = ["usermod"]
|
||||||
|
if groups_to_add:
|
||||||
|
cmd += ["-a", "-G", ",".join(groups_to_add)]
|
||||||
|
|
||||||
|
for first, last in subuids_to_remove:
|
||||||
|
output.print_info(f"Removing subuids {first}-{last} from {user}.")
|
||||||
|
cmd += ["--del-subuids", f"{first}-{last}"]
|
||||||
|
|
||||||
|
for first, last in subuids_to_add:
|
||||||
|
output.print_info(f"Adding subuids {first}-{last} to {user}.")
|
||||||
|
cmd += ["--add-subuids", f"{first}-{last}"]
|
||||||
|
|
||||||
|
for first, last in subgids_to_remove:
|
||||||
|
output.print_info(f"Removing subgids {first}-{last} from {user}.")
|
||||||
|
cmd += ["--del-subgids", f"{first}-{last}"]
|
||||||
|
|
||||||
|
for first, last in subgids_to_add:
|
||||||
|
output.print_info(f"Adding subgids {first}-{last} to {user}.")
|
||||||
|
cmd += ["--add-subgids", f"{first}-{last}"]
|
||||||
|
|
||||||
|
if len(cmd) > 1:
|
||||||
|
cmd.append(user)
|
||||||
|
command.prg(cmd, pty=False)
|
||||||
|
|
||||||
|
# Set these only if things change, no need to clutter the store otherwise
|
||||||
|
store["usermanager_user_additional_groups"][user] = new_groups
|
||||||
|
store["usermanager_user_subuids"][user] = new_subuids
|
||||||
|
store["usermanager_user_subgids"][user] = new_subgids
|
||||||
|
|
||||||
|
def _delete_users_and_groups(self, store: _store.Store):
|
||||||
|
store.ensure("usermanager_users", set())
|
||||||
|
store.ensure("usermanager_groups", set())
|
||||||
|
|
||||||
|
managed_users = set(map(lambda u: u.username, self.users))
|
||||||
|
managed_groups = set(map(lambda g: g.groupname, self.groups))
|
||||||
|
|
||||||
|
groups_to_remove = store["usermanager_groups"] - managed_groups
|
||||||
|
users_to_remove = store["usermanager_users"] - managed_users
|
||||||
|
|
||||||
|
for user in users_to_remove:
|
||||||
|
output.print_info(f"Deleting user {user}.")
|
||||||
|
command.prg(["userdel", user], pty=False)
|
||||||
|
|
||||||
|
store["usermanager_users"] = managed_users
|
||||||
|
|
||||||
|
for group in groups_to_remove:
|
||||||
|
output.print_info(f"Deleting group {group}.")
|
||||||
|
command.prg(["groupdel", group], pty=False)
|
||||||
|
|
||||||
|
store["usermanager_groups"] = managed_groups
|
||||||
|
|
||||||
|
def before_update(self, store: _store.Store):
|
||||||
|
for group in self.groups:
|
||||||
|
self._check_group(group)
|
||||||
|
user_groups_index = self._user_groups_index()
|
||||||
|
for user in self.users:
|
||||||
|
self._check_user(user, user_groups_index)
|
||||||
|
|
||||||
|
self._delete_users_and_groups(store)
|
||||||
|
|
||||||
|
def after_update(self, store: _store.Store):
|
||||||
|
# Iterate all entries to ensure removals take place
|
||||||
|
for user in pwd.getpwall():
|
||||||
|
self._modify_user_groups_subids(user.pw_name, store)
|
||||||
Reference in New Issue
Block a user