Lands Gizmoduck's round 3 candidate, manifest 4782c8b5ecb7 (c783d5e39on gizmoduck/row234-r3, one commit on57f59e0c3), approved by Duckworth: content on round 1 (861182fa6, 29919), the round 2 rebase check (29928, rev 2670) and round 3 (REVIEW-r3.md7c8c38b69, posted 29935, rev 2683). 41 paths. Round 3 fixes the two failures in Sage's combined gate with row 233: bus schema.test.mjs now counts 18 tables and 71 triggers for the new hosts and host_grants tables, and stack-release.test.mjs T46 puts both tables in the untimed restore-guard list, since every write shares a transaction with a grant.* event. Round 3 rebuilt on the row 233 landing as one commit. 38 of the 39 round 2 blobs are unchanged and BUILD.md is append-only. Gizmoduck's singles on round 3: stack-release 196/0, schema 1/0. Evidence: Gizmoduck's slot runs, 25 of 25 mutants killed (A22b and A23 included, A9 structural), singles on r2 387/0 over 47 files. Sage's landing gate on thec783d5e39tree: 06:02:12Z to 06:15:26Z, every package except queue 2890 pass, 0 fail (15 packages; bus 539, cli 622), and all ten scripts/test-*.sh 585 pass, 0 fail. This is code only. The live stack runs from a pinned copy at schema 3i. The 3i to 3j migration runs in the 10-24 window (mos-1145), agent-run under Q173, with a backup first. Duckworth's N10 (hosts.mjs Object.hasOwn) and N12 (doc-only) go to row 235's carry list.
286 lines
9.5 KiB
Python
Executable File
286 lines
9.5 KiB
Python
Executable File
#!/usr/bin/python3 -I
|
|
# Row 234 (#1740, #436a row a), design r6 E3: the root half of host enrollment.
|
|
#
|
|
# Installed as /usr/local/sbin/mosaic-keys-write, root, 0755. mosaic-keys (the installer key's forced
|
|
# command) runs it through `sudo -n` with one request:
|
|
#
|
|
# list <user> each line of /etc/ssh/mosaic-keys/<user> with a mosaic- tag
|
|
# add <user> <type> <base64> <tag> appends from="<from>",restrict <type> <base64> <tag>, unless the tag exists
|
|
# remove <user> <tag> drops the line with that exact tag; a missing tag changes nothing
|
|
#
|
|
# It checks the request again, whatever mosaic-keys did. Host config is root-owned and written by ops:
|
|
# /etc/mosaic-keys/users (the users it may touch), /etc/mosaic-keys/privileged (users with sudo that
|
|
# Jason approved by name) and /etc/mosaic-keys/from (the from= value). Standard library only. Exit 0
|
|
# on success, 1 on any refusal, with the reason on stderr and in syslog.
|
|
import base64
|
|
import fcntl
|
|
import grp
|
|
import hashlib
|
|
import os
|
|
import pwd
|
|
import re
|
|
import socket
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import syslog
|
|
|
|
KEY_DIR = '/etc/ssh/mosaic-keys'
|
|
CONF_DIR = '/etc/mosaic-keys'
|
|
USER = re.compile(r'[a-z_][a-z0-9_-]{0,31}')
|
|
TAG = re.compile(r'mosaic-[0-9a-f]{32}')
|
|
KEY_TYPE = re.compile(r'ssh-ed25519|ecdsa-sha2-nistp256|ssh-rsa')
|
|
BASE64 = re.compile(r'[A-Za-z0-9+/]+={0,2}')
|
|
FROM = re.compile(r'[0-9A-Za-z.:/*?!_-]+(,[0-9A-Za-z.:/*?!_-]+)*')
|
|
# In the code, not in a file: no request may name these, whatever users says.
|
|
RESERVED_USERS = ('root', 'localadmin', 'mosaic-installer')
|
|
MIN_UID = 1000
|
|
# Each is root by another route. disk reads and writes raw block devices, libvirt and incus-admin start
|
|
# guests with host mounts (r6 N5). privileged lifts this check and nothing else.
|
|
REFUSED_GROUPS = ('sudo', 'admin', 'wheel', 'docker', 'lxd', 'disk', 'libvirt', 'incus-admin')
|
|
LOCK = '.lock'
|
|
|
|
|
|
class Refused(Exception):
|
|
pass
|
|
|
|
|
|
def _passwd(user):
|
|
try:
|
|
return pwd.getpwnam(user).pw_uid
|
|
except KeyError:
|
|
return None
|
|
|
|
|
|
def _groups(user):
|
|
entry = pwd.getpwnam(user)
|
|
names = set()
|
|
for gid in os.getgrouplist(user, entry.pw_gid):
|
|
try:
|
|
names.add(grp.getgrgid(gid).gr_name)
|
|
except KeyError:
|
|
pass
|
|
return names
|
|
|
|
|
|
def _sudo(user):
|
|
try:
|
|
out = subprocess.run(
|
|
['/usr/bin/sudo', '-l', '-U', user],
|
|
env={'LC_ALL': 'C', 'PATH': '/usr/bin:/bin'},
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
timeout=10,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
# No answer is not the expected line, so the caller refuses user-sudo and logs it (duckworth-0081 N2).
|
|
return ''
|
|
return out.stdout.decode('utf-8', 'replace')
|
|
|
|
|
|
def _log(message):
|
|
syslog.openlog('mosaic-keys-write', 0, syslog.LOG_AUTH)
|
|
syslog.syslog(syslog.LOG_INFO, message)
|
|
|
|
|
|
class Host:
|
|
# What the writer reads from the machine. Tests pass their own dirs, owner and lookups.
|
|
def __init__(self, key_dir=KEY_DIR, conf_dir=CONF_DIR, owner=0, passwd=_passwd, groups=_groups,
|
|
sudo=_sudo, hostname=None, log=_log):
|
|
self.key_dir = key_dir
|
|
self.conf_dir = conf_dir
|
|
self.owner = owner
|
|
self.passwd = passwd
|
|
self.groups = groups
|
|
self.sudo = sudo
|
|
self.hostname = hostname if hostname is not None else socket.gethostname().split('.')[0]
|
|
self.log = log
|
|
|
|
|
|
def fingerprint(blob64):
|
|
digest = hashlib.sha256(base64.b64decode(blob64, validate=True)).digest()
|
|
return 'SHA256:' + base64.b64encode(digest).decode().rstrip('=')
|
|
|
|
|
|
def parse(argv):
|
|
# The request's shape and every pattern, before any name reaches a path.
|
|
if not argv:
|
|
raise Refused('request')
|
|
verb, args = argv[0], argv[1:]
|
|
shapes = {'list': 1, 'add': 4, 'remove': 2}
|
|
if verb not in shapes or len(args) != shapes[verb]:
|
|
raise Refused('request')
|
|
if not USER.fullmatch(args[0]):
|
|
raise Refused('user-pattern')
|
|
req = {'verb': verb, 'user': args[0]}
|
|
if verb == 'add':
|
|
if not KEY_TYPE.fullmatch(args[1]) or not BASE64.fullmatch(args[2]):
|
|
raise Refused('key-format')
|
|
try:
|
|
req['fingerprint'] = fingerprint(args[2])
|
|
except ValueError:
|
|
raise Refused('key-format')
|
|
req['type'], req['base64'] = args[1], args[2]
|
|
if verb in ('add', 'remove'):
|
|
if not TAG.fullmatch(args[-1]):
|
|
raise Refused('tag-pattern')
|
|
req['tag'] = args[-1]
|
|
return req
|
|
|
|
|
|
def _owned(st, host, kind):
|
|
if st.st_uid != host.owner or st.st_mode & 0o022 or not kind(st.st_mode):
|
|
raise Refused('not-root-owned')
|
|
|
|
|
|
def read_conf(host, name):
|
|
# A root-owned regular file, not group or world writable, opened without following a symlink.
|
|
try:
|
|
fd = os.open(os.path.join(host.conf_dir, name), os.O_RDONLY | os.O_NOFOLLOW)
|
|
except OSError:
|
|
raise Refused('config-' + name)
|
|
try:
|
|
_owned(os.fstat(fd), host, stat.S_ISREG)
|
|
with os.fdopen(os.dup(fd), 'r', encoding='utf-8') as f:
|
|
text = f.read()
|
|
finally:
|
|
os.close(fd)
|
|
return [line.strip() for line in text.splitlines() if line.strip() and not line.strip().startswith('#')]
|
|
|
|
|
|
def check_user(req, host):
|
|
user = req['user']
|
|
users = read_conf(host, 'users')
|
|
privileged = read_conf(host, 'privileged')
|
|
if user in RESERVED_USERS or user not in users:
|
|
raise Refused('user-not-listed')
|
|
uid = host.passwd(user)
|
|
if uid is None:
|
|
raise Refused('user-unknown')
|
|
if uid == 0 or uid < MIN_UID:
|
|
raise Refused('user-system')
|
|
if user in privileged:
|
|
return
|
|
if set(REFUSED_GROUPS) & set(host.groups(user)):
|
|
raise Refused('user-group')
|
|
expected = 'User %s is not allowed to run sudo on %s.' % (user, host.hostname)
|
|
out = host.sudo(user)
|
|
if out not in (expected, expected + '\n'):
|
|
raise Refused('user-sudo')
|
|
|
|
|
|
def read_from(host):
|
|
lines = read_conf(host, 'from')
|
|
if len(lines) != 1 or not FROM.fullmatch(lines[0]):
|
|
raise Refused('config-from')
|
|
return lines[0]
|
|
|
|
|
|
def tag_of(line):
|
|
parts = line.split()
|
|
return parts[-1] if parts and TAG.fullmatch(parts[-1]) else None
|
|
|
|
|
|
def read_lines(dir_fd, user, host):
|
|
# ENOENT means an empty list: a first add finds no file (r6 N6).
|
|
try:
|
|
fd = os.open(user, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=dir_fd)
|
|
except FileNotFoundError:
|
|
return []
|
|
except OSError:
|
|
raise Refused('user-file')
|
|
try:
|
|
_owned(os.fstat(fd), host, stat.S_ISREG)
|
|
with os.fdopen(os.dup(fd), 'r', encoding='utf-8') as f:
|
|
return f.read().splitlines()
|
|
finally:
|
|
os.close(fd)
|
|
|
|
|
|
def write_lines(dir_fd, user, lines, host):
|
|
tmp = '.' + user + '.tmp'
|
|
try:
|
|
st = os.stat(tmp, dir_fd=dir_fd, follow_symlinks=False)
|
|
except FileNotFoundError:
|
|
st = None
|
|
if st is not None:
|
|
# Under the flock a leftover tmp can only come from a writer that crashed.
|
|
if not stat.S_ISREG(st.st_mode) or st.st_uid != host.owner:
|
|
raise Refused('tmp-file')
|
|
os.unlink(tmp, dir_fd=dir_fd)
|
|
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o644, dir_fd=dir_fd)
|
|
try:
|
|
os.fchmod(fd, 0o644)
|
|
os.write(fd, ''.join(line + '\n' for line in lines).encode('utf-8'))
|
|
os.fsync(fd)
|
|
finally:
|
|
os.close(fd)
|
|
os.rename(tmp, user, src_dir_fd=dir_fd, dst_dir_fd=dir_fd)
|
|
os.fsync(dir_fd)
|
|
|
|
|
|
def run(req, host, out):
|
|
check_user(req, host)
|
|
line_from = read_from(host) if req['verb'] == 'add' else None
|
|
try:
|
|
dir_fd = os.open(host.key_dir, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
except OSError:
|
|
raise Refused('key-dir')
|
|
try:
|
|
_owned(os.fstat(dir_fd), host, stat.S_ISDIR)
|
|
lock = os.open(LOCK, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600, dir_fd=dir_fd)
|
|
try:
|
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
user = req['user']
|
|
lines = read_lines(dir_fd, user, host)
|
|
if req['verb'] == 'list':
|
|
for line in lines:
|
|
if tag_of(line):
|
|
out.write(line + '\n')
|
|
return 'listed'
|
|
if req['verb'] == 'add':
|
|
if any(tag_of(line) == req['tag'] for line in lines):
|
|
return 'present'
|
|
entry = 'from="%s",restrict %s %s %s' % (line_from, req['type'], req['base64'], req['tag'])
|
|
write_lines(dir_fd, user, lines + [entry], host)
|
|
return 'added'
|
|
kept = [line for line in lines if tag_of(line) != req['tag']]
|
|
if len(kept) == len(lines):
|
|
return 'absent'
|
|
write_lines(dir_fd, user, kept, host)
|
|
return 'removed'
|
|
finally:
|
|
os.close(lock)
|
|
finally:
|
|
os.close(dir_fd)
|
|
|
|
|
|
def main(argv, host, out=sys.stdout, err=sys.stderr):
|
|
req = None
|
|
try:
|
|
req = parse(argv)
|
|
result = run(req, host, out)
|
|
code = 0
|
|
except Refused as e:
|
|
result = 'refused ' + str(e)
|
|
code = 1
|
|
except OSError as e:
|
|
result = 'refused os-' + (e.__class__.__name__)
|
|
code = 1
|
|
fields = ['result=' + result]
|
|
if req:
|
|
fields.append('user=' + req['user'])
|
|
if 'tag' in req:
|
|
fields.append('tag=' + req['tag'])
|
|
if 'fingerprint' in req:
|
|
fields.append('key=' + req['fingerprint'])
|
|
host.log(' '.join(fields))
|
|
if code:
|
|
err.write('mosaic-keys-write: ' + result + '\n')
|
|
return code
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main(sys.argv[1:], Host()))
|