summaryrefslogtreecommitdiffstatshomepage
path: root/scripts/canonical_subids.py
blob: c99891e9d1baccc22bdf39454b909bad6a7b2082 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
"""Assign missing local subordinate IDs without changing existing allocations."""

import argparse
import ctypes
import grp
import os
import pwd
import subprocess
import tempfile
from contextlib import contextmanager
from pathlib import Path


@contextmanager
def account_lock():
    libc = ctypes.CDLL(None, use_errno=True)
    if libc.lckpwdf() != 0:
        raise OSError("Cannot lock the account files; try again later.")
    try:
        yield
    finally:
        libc.ulckpwdf()


def allocation_policy(etc: Path) -> dict[str, int]:
    for line in (etc / "nsswitch.conf").read_text().splitlines():
        key, _, value = line.split("#", 1)[0].partition(":")
        if key.strip() == "subid" and value.split() != ["files"]:
            raise ValueError(
                "Subordinate IDs use an external provider; no files changed."
            )
    policy = {}
    for line in (etc / "login.defs").read_text().splitlines():
        fields = line.split("#", 1)[0].split()
        if fields and fields[0] in {
            f"SUB_{kind}_{limit}"
            for kind in ("UID", "GID")
            for limit in ("MIN", "MAX", "COUNT")
        }:
            key, value = fields
            policy[key] = int(value)
    return policy


def read_ranges(text: str) -> list[tuple[str, int, int]]:
    ranges = []
    for line in text.splitlines():
        if not line.strip() or line.lstrip().startswith("#"):
            continue
        owner, first, size = line.split(":")
        start, count = int(first), int(size)
        if not owner or start < 0 or count <= 0 or start + count > 2**32 - 1:
            raise ValueError("Invalid subordinate ID range; no files changed.")
        ranges.append((owner, start, count))
    return ranges


def free_range(
    occupied: list[tuple[int, int]], minimum: int, maximum: int, count: int
) -> int:
    start = minimum
    for first, end in sorted(occupied):
        if start + count <= first:
            break
        start = max(start, end)
    if start + count - 1 > maximum:
        raise ValueError("No free subordinate ID range within login.defs limits.")
    return start


def allocation_limits(policy: dict[str, int], kind: str) -> tuple[int, int, int]:
    minimum = policy.get(f"SUB_{kind}_MIN", 100000)
    maximum = policy.get(f"SUB_{kind}_MAX", 600100000)
    count = policy.get(f"SUB_{kind}_COUNT", 65536)
    if count == 0:
        raise ValueError(f"Subordinate {kind} allocation is disabled in login.defs.")
    if not (0 < minimum <= maximum < 2**32 - 1) or count < 65536:
        raise ValueError(f"Invalid or insufficient SUB_{kind} limits in login.defs.")
    return minimum, maximum, count


def plan_allocations(
    etc: Path, name: str, uid: int, user_ids: set[int], group_ids: set[int]
) -> dict[Path, str]:
    policy = allocation_policy(etc)
    changes = {}
    for kind, used in (("UID", user_ids), ("GID", group_ids)):
        path = etc / f"sub{kind.lower()}"
        if path.is_symlink():
            raise ValueError(f"Refusing to replace symlink: {path}")
        text = path.read_text() if path.exists() else ""
        ranges = read_ranges(text)
        if any(owner in {name, str(uid)} for owner, _, _ in ranges):
            continue
        minimum, maximum, count = allocation_limits(policy, kind)
        occupied = [(first, first + size) for _, first, size in ranges]
        occupied.extend((number, number + 1) for number in used | {uid})
        start = free_range(occupied, minimum, maximum, count)
        separator = "\n" if text and not text.endswith("\n") else ""
        changes[path] = f"{text}{separator}{name}:{start}:{count}\n"
    return changes


def write_atomic(path: Path, text: str) -> None:
    info = path.stat() if path.exists() else None
    with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, delete=False) as stream:
        temporary = Path(stream.name)
        try:
            stream.write(text)
            stream.flush()
            if info:
                os.fchown(stream.fileno(), info.st_uid, info.st_gid)
            os.fchmod(stream.fileno(), (info.st_mode & 0o777) if info else 0o644)
            os.fsync(stream.fileno())
            os.replace(temporary, path)
        finally:
            temporary.unlink(missing_ok=True)


def configure(name: str) -> None:
    account = pwd.getpwnam(name)
    if account.pw_uid == 0 or any(char in name for char in ":\n\r"):
        raise ValueError("Select a non-root login account.")
    with account_lock():
        changes = plan_allocations(
            Path("/etc"),
            name,
            account.pw_uid,
            {entry.pw_uid for entry in pwd.getpwall()},
            {entry.gr_gid for entry in grp.getgrall()} | {account.pw_gid},
        )
        for path, text in changes.items():
            write_atomic(path, text)
            print(f"Assigned a subordinate ID range in {path} for {name}.")
    for flags in ([], ["-g"]):
        subprocess.run(["/usr/bin/getsubids", *flags, name], check=True)


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("user")
    args = parser.parse_args()
    if os.geteuid() != 0:
        raise SystemExit(
            "Run this command through sudo with Ubuntu's /usr/bin/python3."
        )
    configure(args.user)


if __name__ == "__main__":
    main()