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 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678
|
# Copyright (C) 2016 Matt Dainty
# Copyright (C) 2020 Dermot Bradley
#
# Author: Matt Dainty <matt@bodgit-n-scarper.com>
# Author: Dermot Bradley <dermot_bradley@yahoo.com>
#
# This file is part of cloud-init. See LICENSE file for license information.
import logging
import os
import re
import stat
from datetime import datetime
from typing import Any, Dict, Optional
from cloudinit import distros, helpers, lifecycle, subp, util
from cloudinit.distros.parsers.hostname import HostnameConf
from cloudinit.settings import PER_ALWAYS, PER_INSTANCE
LOG = logging.getLogger(__name__)
NETWORK_FILE_HEADER = """\
# This file is generated from information provided by the datasource. Changes
# to it will not persist across an instance reboot. To disable cloud-init's
# network configuration capabilities, write a file
# /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg with the following:
# network: {config: disabled}
"""
class Distro(distros.Distro):
pip_package_name = "py3-pip"
keymap_path = "/usr/share/bkeymaps/"
locale_conf_fn = "/etc/profile.d/50-cloud-init-locale.sh"
network_conf_fn = "/etc/network/interfaces"
shadow_fn = "/etc/shadow"
renderer_configs = {
"eni": {"eni_path": network_conf_fn, "eni_header": NETWORK_FILE_HEADER}
}
# Alpine stores dhclient leases at following location:
# /var/lib/dhcp/dhclient.leases
dhclient_lease_directory = "/var/lib/dhcp"
dhclient_lease_file_regex = r"dhclient\.leases"
def __init__(self, name, cfg, paths):
distros.Distro.__init__(self, name, cfg, paths)
# This will be used to restrict certain
# calls from repeatedly happening (when they
# should only happen say once per instance...)
self._runner = helpers.Runners(paths)
self.default_locale = "C.UTF-8"
self.osfamily = "alpine"
cfg["ssh_svcname"] = "sshd"
def get_locale(self):
"""The default locale for Alpine Linux is different than
cloud-init's DataSource default.
"""
return self.default_locale
def apply_locale(self, locale, out_fn=None):
# Alpine has limited locale support due to musl library limitations
if not locale:
locale = self.default_locale
if not out_fn:
out_fn = self.locale_conf_fn
lines = [
"#",
"# This file is created by cloud-init once per new instance boot",
"#",
"export CHARSET=UTF-8",
"export LANG=%s" % locale,
"export LC_COLLATE=C",
"",
]
util.write_file(out_fn, "\n".join(lines), 0o644)
def install_packages(self, pkglist: distros.PackageList):
self.update_package_sources()
self.package_command("add", pkgs=pkglist)
def _write_hostname(self, hostname, filename):
conf = None
try:
# Try to update the previous one
# so lets see if we can read it first.
conf = self._read_hostname_conf(filename)
except IOError:
create_hostname_file = util.get_cfg_option_bool(
self._cfg, "create_hostname_file", True
)
if create_hostname_file:
pass
else:
LOG.info(
"create_hostname_file is False; hostname file not created"
)
return
if not conf:
conf = HostnameConf("")
conf.set_hostname(hostname)
util.write_file(filename, str(conf), 0o644)
def _read_system_hostname(self):
sys_hostname = self._read_hostname(self.hostname_conf_fn)
return (self.hostname_conf_fn, sys_hostname)
def _read_hostname_conf(self, filename):
conf = HostnameConf(util.load_text_file(filename))
conf.parse()
return conf
def _read_hostname(self, filename, default=None):
hostname = None
try:
conf = self._read_hostname_conf(filename)
hostname = conf.hostname
except IOError:
pass
if not hostname:
return default
return hostname
def _get_localhost_ip(self):
return "127.0.1.1"
def set_keymap(self, layout: str, model: str, variant: str, options: str):
if not layout:
msg = "Keyboard layout not specified."
LOG.error(msg)
raise RuntimeError(msg)
keymap_layout_path = os.path.join(self.keymap_path, layout)
if not os.path.isdir(keymap_layout_path):
msg = (
"Keyboard layout directory %s does not exist."
% keymap_layout_path
)
LOG.error(msg)
raise RuntimeError(msg)
if not variant:
msg = "Keyboard variant not specified."
LOG.error(msg)
raise RuntimeError(msg)
keymap_variant_path = os.path.join(
keymap_layout_path, "%s.bmap.gz" % variant
)
if not os.path.isfile(keymap_variant_path):
msg = (
"Keyboard variant file %s does not exist."
% keymap_variant_path
)
LOG.error(msg)
raise RuntimeError(msg)
if model:
LOG.warning("Keyboard model is ignored for Alpine Linux.")
if options:
LOG.warning("Keyboard options are ignored for Alpine Linux.")
subp.subp(["setup-keymap", layout, variant])
def set_timezone(self, tz):
distros.set_etc_timezone(tz=tz, tz_file=self._find_tz_file(tz))
def package_command(self, command, args=None, pkgs=None):
if pkgs is None:
pkgs = []
cmd = ["apk"]
# Redirect output
cmd.append("--quiet")
if args and isinstance(args, str):
cmd.append(args)
elif args and isinstance(args, list):
cmd.extend(args)
if command:
cmd.append(command)
if command == "upgrade":
cmd.extend(["--update-cache", "--available"])
pkglist = util.expand_package_list("%s-%s", pkgs)
cmd.extend(pkglist)
# Allow the output of this to flow outwards (ie not be captured)
subp.subp(cmd, capture=False)
def update_package_sources(self, *, force=False):
self._runner.run(
"update-sources",
self.package_command,
["update"],
freq=PER_ALWAYS if force else PER_INSTANCE,
)
@property
def preferred_ntp_clients(self):
"""Allow distro to determine the preferred ntp client list"""
if not self._preferred_ntp_clients:
self._preferred_ntp_clients = ["chrony", "ntp"]
return self._preferred_ntp_clients
def add_user(self, name, **kwargs) -> bool:
"""
Add a user to the system using standard tools
On Alpine this may use either 'useradd' or 'adduser' depending
on whether the 'shadow' package is installed.
Returns False if user already exists, otherwise True.
"""
if util.is_user(name):
LOG.info("User %s already exists, skipping.", name)
return False
if "selinux_user" in kwargs:
LOG.warning("Ignoring selinux_user parameter for Alpine Linux")
del kwargs["selinux_user"]
# If 'useradd' is available then use the generic
# add_user function from __init__.py instead.
if subp.which("useradd"):
return super().add_user(name, **kwargs)
create_groups = kwargs.pop("create_groups", True)
adduser_cmd = ["adduser", "-D"]
# Since we are creating users, we want to carefully validate
# the inputs. If something goes wrong, we can end up with a
# system that nobody can login to.
adduser_opts = {
"gecos": "-g",
"homedir": "-h",
"primary_group": "-G",
"shell": "-s",
"uid": "-u",
}
adduser_flags = {"system": "-S"}
# support kwargs having groups=[list] or groups="g1,g2"
groups = kwargs.get("groups")
if groups:
if isinstance(groups, str):
groups = groups.split(",")
elif isinstance(groups, dict):
lifecycle.deprecate(
deprecated=f"The user {name} has a 'groups' config value "
"of type dict",
deprecated_version="22.3",
extra_message="Use a comma-delimited string or "
"array instead: group1,group2.",
)
# remove any white spaces in group names, most likely
# that came in as a string like: groups: group1, group2
groups = [g.strip() for g in groups]
# kwargs.items loop below wants a comma delimited string
# that can go right through to the command.
kwargs["groups"] = ",".join(groups)
if kwargs.get("primary_group"):
groups.append(kwargs["primary_group"])
if create_groups and groups:
for group in groups:
if not util.is_group(group):
self.create_group(group)
LOG.debug("created group '%s' for user '%s'", group, name)
if "uid" in kwargs:
kwargs["uid"] = str(kwargs["uid"])
unsupported_busybox_values: Dict[str, Any] = {
"groups": [],
"expiredate": None,
"inactive": None,
"passwd": None,
}
# Check the values and create the command
for key, val in sorted(kwargs.items()):
if key in adduser_opts and val and isinstance(val, str):
adduser_cmd.extend([adduser_opts[key], val])
elif (
key in unsupported_busybox_values
and val
and isinstance(val, str)
):
# Busybox's 'adduser' does not support specifying these
# options so store them for use via alternative means.
if key == "groups":
unsupported_busybox_values[key] = val.split(",")
else:
unsupported_busybox_values[key] = val
elif key in adduser_flags and val:
adduser_cmd.append(adduser_flags[key])
# Don't create the home directory if directed so
# or if the user is a system user
if kwargs.get("no_create_home") or kwargs.get("system"):
adduser_cmd.append("-H")
# Busybox's 'adduser' puts username at end of command
adduser_cmd.append(name)
# Run the command
LOG.debug("Adding user %s", name)
try:
subp.subp(adduser_cmd)
except subp.ProcessExecutionError as e:
LOG.warning("Failed to create user %s", name)
raise e
# Process remaining options that Busybox's 'adduser' does not support
# Separately add user to each additional group as Busybox's
# 'adduser' does not support specifying additional groups.
for addn_group in unsupported_busybox_values[
"groups"
]: # pylint: disable=E1133
LOG.debug("Adding user to group %s", addn_group)
try:
subp.subp(["addgroup", name, addn_group])
except subp.ProcessExecutionError as e:
util.logexc(
LOG, "Failed to add user %s to group %s", name, addn_group
)
raise e
if unsupported_busybox_values["passwd"]:
# Separately set password as Busybox's 'adduser' does
# not support passing password as CLI option.
super().set_passwd(
name, unsupported_busybox_values["passwd"], hashed=True
)
# Busybox's 'adduser' is hardcoded to always set the following field
# values (numbered from "0") in /etc/shadow unlike 'useradd':
#
# Field Value set
#
# 3 minimum password age 0 (no min age)
# 4 maximum password age 99999 (days)
# 5 warning period 7 (warn days before max age)
#
# so modify these fields to be empty.
#
# Also set expiredate (field '7') and/or inactive (field '6')
# values directly in /etc/shadow file as Busybox's 'adduser'
# does not support passing these as CLI options.
expiredate = unsupported_busybox_values["expiredate"]
inactive = unsupported_busybox_values["inactive"]
shadow_contents = None
shadow_file = self.shadow_fn
try:
shadow_contents = util.load_text_file(shadow_file)
except FileNotFoundError as e:
LOG.warning("Failed to read %s file, file not found", shadow_file)
raise e
# Find the line in /etc/shadow for the user
original_line = None
for line in shadow_contents.splitlines():
new_line_parts = line.split(":")
if new_line_parts[0] == name:
original_line = line
break
if original_line:
# Modify field(s) in copy of user's shadow file entry
update_type = ""
# Minimum password age
new_line_parts[3] = ""
# Maximum password age
new_line_parts[4] = ""
# Password warning period
new_line_parts[5] = ""
update_type = "password aging"
if expiredate is not None:
# Convert date into number of days since 1st Jan 1970
days = (
datetime.fromisoformat(expiredate)
- datetime.fromisoformat("1970-01-01")
).days
new_line_parts[7] = str(days)
if update_type != "":
update_type = update_type + " & "
update_type = update_type + "acct expiration date"
if inactive is not None:
new_line_parts[6] = inactive
if update_type != "":
update_type = update_type + " & "
update_type = update_type + "inactivity period"
# Replace existing line for user with modified line
shadow_contents = shadow_contents.replace(
original_line, ":".join(new_line_parts)
)
LOG.debug("Updating %s for user %s", update_type, name)
try:
util.write_file(
shadow_file, shadow_contents, omode="w", preserve_mode=True
)
except IOError as e:
util.logexc(LOG, "Failed to update %s file", shadow_file)
raise e
else:
util.logexc(
LOG, "Failed to update %s for user %s", shadow_file, name
)
# Indicate that a new user was created
return True
def lock_passwd(self, name):
"""
Lock the password of a user, i.e., disable password logins
"""
# Check whether Shadow's or Busybox's version of 'passwd'.
# If Shadow's 'passwd' is available then use the generic
# lock_passwd function from __init__.py instead.
if not os.path.islink(
"/usr/bin/passwd"
) or "bbsuid" not in os.readlink("/usr/bin/passwd"):
return super().lock_passwd(name)
cmd = ["passwd", "-l", name]
# Busybox's 'passwd', unlike Shadow's 'passwd', errors
# if password is already locked:
#
# "passwd: password for user2 is already locked"
#
# with exit code 1
try:
(_out, err) = subp.subp(cmd, rcs=[0, 1])
if re.search(r"is already locked", err):
return True
except subp.ProcessExecutionError as e:
util.logexc(LOG, "Failed to disable password for user %s", name)
raise e
def unlock_passwd(self, name: str):
"""
Unlock the password of a user, i.e., enable password logins
"""
# Check whether Shadow's or Busybox's version of 'passwd'.
# If Shadow's 'passwd' is available then use the generic
# lock_passwd function from __init__.py instead.
if not os.path.islink(
"/usr/bin/passwd"
) or "bbsuid" not in os.readlink("/usr/bin/passwd"):
return super().unlock_passwd(name)
cmd = ["passwd", "-u", name]
# Busybox's 'passwd', unlike Shadow's 'passwd', errors
# if password is already unlocked:
#
# "passwd: password for user2 is already unlocked"
#
# with exit code 1
#
# and also does *not* error if no password is set.
try:
_, err = subp.subp(cmd, rcs=[0, 1])
if re.search(r"is already unlocked", err):
return True
except subp.ProcessExecutionError as e:
util.logexc(LOG, "Failed to unlock password for user %s", name)
raise e
def expire_passwd(self, user):
# Check whether Shadow's or Busybox's version of 'passwd'.
# If Shadow's 'passwd' is available then use the generic
# expire_passwd function from __init__.py instead.
if not os.path.islink(
"/usr/bin/passwd"
) or "bbsuid" not in os.readlink("/usr/bin/passwd"):
return super().expire_passwd(user)
# Busybox's 'passwd' does not provide an expire option
# so have to manipulate the shadow file directly.
shadow_contents = None
shadow_file = self.shadow_fn
try:
shadow_contents = util.load_text_file(shadow_file)
except FileNotFoundError as e:
LOG.warning("Failed to read %s file, file not found", shadow_file)
raise e
# Find the line in /etc/shadow for the user
original_line = None
for line in shadow_contents.splitlines():
new_line_parts = line.split(":")
if new_line_parts[0] == user:
LOG.debug("Found /etc/shadow line matching user %s", user)
original_line = line
break
if original_line:
# Replace existing line for user with modified line
#
# Field '2' (numbered from '0') in /etc/shadow
# is the "date of last password change".
if new_line_parts[2] != "0":
# Busybox's 'adduser' always expires password so only
# need to expire it now if this is not a new user.
new_line_parts[2] = "0"
shadow_contents = shadow_contents.replace(
original_line, ":".join(new_line_parts), 1
)
LOG.debug("Expiring password for user %s", user)
try:
util.write_file(
shadow_file,
shadow_contents,
omode="w",
preserve_mode=True,
)
except IOError as e:
util.logexc(LOG, "Failed to update %s file", shadow_file)
raise e
else:
LOG.debug("Password for user %s is already expired", user)
else:
util.logexc(LOG, "Failed to set 'expire' for %s", user)
def create_group(self, name, members=None):
# If 'groupadd' is available then use the generic
# create_group function from __init__.py instead.
if subp.which("groupadd"):
return super().create_group(name, members)
group_add_cmd = ["addgroup", name]
if not members:
members = []
# Check if group exists, and then add if it doesn't
if util.is_group(name):
LOG.warning("Skipping creation of existing group '%s'", name)
else:
try:
subp.subp(group_add_cmd)
LOG.info("Created new group %s", name)
except subp.ProcessExecutionError:
util.logexc(LOG, "Failed to create group %s", name)
# Add members to the group, if so defined
if len(members) > 0:
for member in members:
if not util.is_user(member):
LOG.warning(
"Unable to add group member '%s' to group '%s'"
"; user does not exist.",
member,
name,
)
continue
subp.subp(["addgroup", member, name])
LOG.info("Added user '%s' to group '%s'", member, name)
def shutdown_command(self, mode="poweroff", delay="now", message=None):
# called from cc_power_state_change.load_power_state
# Alpine has halt/poweroff/reboot, with the following specifics:
# - we use them rather than the generic "shutdown"
# - delay is given with "-d [integer]"
# - the integer is in seconds, cannot be "now", and takes no "+"
# - no message is supported (argument ignored, here)
command = [mode, "-d"]
# Convert delay from minutes to seconds, as Alpine's
# halt/poweroff/reboot commands take seconds rather than minutes.
if delay == "now":
# Alpine's commands do not understand "now".
command += ["0"]
else:
try:
command.append(str(int(delay) * 60))
except ValueError as e:
raise TypeError(
"power_state[delay] must be 'now' or '+m' (minutes)."
" found '%s'." % (delay,)
) from e
return command
@staticmethod
def uses_systemd():
"""
Alpine uses OpenRC, not systemd
"""
return False
@classmethod
def manage_service(
self, action: str, service: str, *extra_args: str, rcs=None
):
"""
Perform the requested action on a service. This handles OpenRC
specific implementation details.
OpenRC has two distinct commands relating to services,
'rc-service' and 'rc-update' and the order of their argument
lists differ.
May raise ProcessExecutionError
"""
init_cmd = ["rc-service", "--nocolor"]
update_cmd = ["rc-update", "--nocolor"]
cmds = {
"stop": list(init_cmd) + [service, "stop"],
"start": list(init_cmd) + [service, "start"],
"disable": list(update_cmd) + ["del", service],
"enable": list(update_cmd) + ["add", service],
"restart": list(init_cmd) + [service, "restart"],
"reload": list(init_cmd) + [service, "restart"],
"try-reload": list(init_cmd) + [service, "restart"],
"status": list(init_cmd) + [service, "status"],
}
cmd = list(cmds[action])
return subp.subp(cmd, capture=True, rcs=rcs)
@staticmethod
def get_mapped_device(blockdev: str) -> Optional[str]:
"""Returns underlying block device for a mapped device.
If it is mapped, blockdev will usually take the form of
/dev/mapper/some_name
If blockdev is a symlink pointing to a /dev/dm-* device, return
the device pointed to. Otherwise, return None.
"""
realpath = os.path.realpath(blockdev)
if blockdev.startswith("/dev/mapper"):
# For Alpine systems a /dev/mapper/ entry is *not* a
# symlink to the related /dev/dm-X block device,
# rather it is a block device itself.
# Get the major/minor of the /dev/mapper block device
major = os.major(os.stat(blockdev).st_rdev)
minor = os.minor(os.stat(blockdev).st_rdev)
# Find the /dev/dm-X device with the same major/minor
with os.scandir("/dev/") as it:
for deventry in it:
if deventry.name.startswith("dm-"):
res = os.lstat(deventry.path)
if stat.S_ISBLK(res.st_mode):
if (
os.major(os.stat(deventry.path).st_rdev)
== major
and os.minor(os.stat(deventry.path).st_rdev)
== minor
):
realpath = os.path.realpath(deventry.path)
break
if realpath.startswith("/dev/dm-"):
LOG.debug(
"%s is a mapped device pointing to %s", blockdev, realpath
)
return realpath
return None
|