✏️ 正在编辑: subids.pm
路径:
/opt/cpanel/ea-podman/lib/ea_podman/subids.pm
提示:
您可以编辑任何文件(包括二进制文件),但请注意不当修改可能导致文件损坏。
#!/usr/local/cpanel/3rdparty/bin/perl # cpanel - ea_podman/subids.pm Copyright 2022 cPanel, L.L.C. # All rights Reserved. # copyright@cpanel.net http://cpanel.net # This code is subject to the cPanel license. Unauthorized copying is prohibited use strict; use warnings; # Despite the name, this module owns the whole root-side rootless-session # bootstrap, not just /etc/subuid and /etc/subgid. If you are looking for where # ea-podman touches systemd as root, it is here. package ea_podman::subids; use Path::Tiny 'path'; use Cpanel::OS; use Fcntl qw(:flock); use Time::HiRes (); our $good = "✅"; our $bad = "❌"; # FOR Testability our $file_subuid = "/etc/subuid"; our $file_subgid = "/etc/subgid"; our $dir_run = "/run/user"; # systemd records a lingering user as an empty marker file in here: # `loginctl enable-linger` creates one, `disable-linger` removes it. Reading # the marker is cheaper — and far easier to test — than shelling out to # `loginctl show-user <user> -p Linger`. our $dir_linger = "/var/lib/systemd/linger"; # Same shape, ea-podman’s own bookkeeping: a marker per account meaning “this # linger is one we turned on”. $dir_linger says an account lingers, never who # asked for it. Root owned, and not packaged so an upgrade leaves it be. our $dir_granted_linger = "/opt/cpanel/ea-podman/granted-linger"; # CageFS 7.6.39+ masks the `user@.service` *template*, so no per-user systemd # manager can start and rootless podman has nothing to talk to. See # docs/container-shell-access.md “CageFS 7.6.39+ masks user@.service” for why # CloudLinux does it and why we cannot just leave it unmasked, and # with_user_manager_unmasked() for what we do about it. (EA4-319) # # A mask is the unit name symlinked to /dev/null. Two possible locations, # because `systemctl mask` writes under /etc and `systemctl mask --runtime` # writes under /run; which one it is in is what a restore has to preserve. our $file_mask_etc = "/etc/systemd/system/user\@.service"; our $file_mask_run = "/run/systemd/system/user\@.service"; # $file_mask_state records an in-progress window, so a run that is killed # outright (where no signal handler gets to fire) still has its mask put back — # by the next run through the guard. our $file_mask_lock = "/opt/cpanel/ea-podman/user-manager-mask.lock"; our $file_mask_state = "/opt/cpanel/ea-podman/user-manager-mask.state"; sub ensure_user_root { my ( $user, $num_uids, $ensure_session, $may_restart ) = @_; $num_uids = 65537 if !$num_uids; $ensure_session = 1 if !defined $ensure_session; _ensure_subids( $user, $num_uids ); # Only an account that has containers — or is about to get its first one — # is given a lingering user session. Lingering every account that merely ran # a command (or got backed up) was CPANEL-55309; the caller that can see the # container registry makes that call, see ea_podman::util::ensure_user(). # # When it does apply it is (idempotently) redone every time, not just on # first subid setup: linger may have been torn down since (e.g. a stale # state or an explicit `loginctl disable-linger`), which would leave a # registered user with no runtime dir. (CPANEL-54037) # $may_restart says the account has no containers, so restarting a manager # that is up but unusable costs nothing. The caller decides because only it # can see the root-owned container registry. See ensure_user_session(). ensure_user_session( $user, may_restart => $may_restart ) if $ensure_session; # Tell podman to ignore uid/gid issues _ensure_storage_conf(); return; } # Allocate /etc/subuid + /etc/subgid ranges for the user, unless they already # have both. A new range starts just past the highest existing allocation; on a # host with no allocations at all the first one is placed so that it ends just # below 190000. # # Read → compute → append has to happen under one lock, or two accounts # bootstrapping at once claim the same range and their containers end up on the # same host uids. One exclusive flock on $file_subuid covers both files: every # allocation comes through here and always does the two together. # # It does not serialize against shadow-utils, which allocates from the same # space under its own locking; the disjointness checks below cover that. sub _ensure_subids { my ( $user, $num_uids ) = @_; # Opened first so the lock is held across everything below, and so a missing # file exists by the time it is read. open my $subuid_fh, ">>", $file_subuid or die "Could not open “$file_subuid”: $!\n"; open my $subgid_fh, ">>", $file_subgid or die "Could not open “$file_subgid”: $!\n"; flock( $subuid_fh, LOCK_EX ) or die "Could not lock “$file_subuid”: $!\n"; my $subuid_ranges = _read_ranges($file_subuid); my $subgid_ranges = _read_ranges($file_subgid); my $has_subuid = @{ _user_ranges( $subuid_ranges, $user ) } ? 1 : 0; my $has_subgid = @{ _user_ranges( $subgid_ranges, $user ) } ? 1 : 0; # Already allocated is not the same as safely allocated: an older unlocked # version or a concurrent `useradd` may have put this range on top of # another account’s. Each half is checked on its own — a shared subuid range # is no better for having the missing subgid range filled in. _assert_range_is_exclusive( $user, $subuid_ranges, $file_subuid ) if $has_subuid; _assert_range_is_exclusive( $user, $subgid_ranges, $file_subgid ) if $has_subgid; return if $has_subuid && $has_subgid; # via the mechanics this means the uids/gids end just below 190000 my $getuid_max = 190000 - $num_uids; my $getgid_max = 190000 - $num_uids; foreach my $range ( @{$subuid_ranges} ) { my $uid = $range->{start} + $range->{count}; $getuid_max = $uid if ( $uid > $getuid_max ); } foreach my $range ( @{$subgid_ranges} ) { my $gid = $range->{start} + $range->{count}; $getgid_max = $gid if ( $gid > $getgid_max ); } $getuid_max++; $getgid_max++; my $num_uids_minus_one = $num_uids - 1; # Checked, not assumed: highest-end-plus-one is only free if every line was # accounted for, and unparsable ones were not (see _read_ranges()). if ( !$has_subuid ) { _assert_allocation_is_free( $user, $getuid_max, $num_uids_minus_one, $subuid_ranges, $file_subuid ); print {$subuid_fh} "$user:$getuid_max:$num_uids_minus_one\n" or die "Could not write to “$file_subuid”: $!\n"; } if ( !$has_subgid ) { _assert_allocation_is_free( $user, $getgid_max, $num_uids_minus_one, $subgid_ranges, $file_subgid ); print {$subgid_fh} "$user:$getgid_max:$num_uids_minus_one\n" or die "Could not write to “$file_subgid”: $!\n"; } # A short write only surfaces on flush, and a truncated line here is worth # dying over. $subuid_fh closes last because that drops the lock. close $subgid_fh or die "Could not write to “$file_subgid”: $!\n"; close $subuid_fh or die "Could not write to “$file_subuid”: $!\n"; return; } # Bootstrap the user’s rootless-podman session *as root*. Historically # ensure_user_root only did `mkdir /run/user/<uid>`, which left rootless # podman without a running user systemd manager or dbus socket — so # `podman generate systemd` + `systemctl --user` failed for any user without # an interactive login (cpsrvd/UAPI, account hooks, `su -`). See CPANEL-54037 # (and UPS-504). # # `loginctl enable-linger <user>`, run as root, instead creates # /run/user/<uid> as a tmpfs *and* — on first enable, see ensure_user_session() # — starts user@<uid>.service (the user systemd manager), persisting both # across logout/reboot — exactly what rootless # container persistence requires. Held in a package variable so tests can # stub the privileged call. our $linger_enabler = \&_enable_linger; sub _enable_linger { my ($user) = @_; system( "loginctl", "enable-linger", $user ); return $? == 0; } # Package variables so tests can stub the privileged calls, same as $linger_enabler. our $daemon_reloader = \&_daemon_reload; our $user_manager_starter = \&_start_user_manager; sub _daemon_reload { system( "systemctl", "daemon-reload" ); return $? == 0; } sub _start_user_manager { my ($uid) = @_; system( "systemctl", "start", "user\@$uid.service" ); return $? == 0; } # The counterpart to $user_manager_starter, needed for one case only: a manager # that is running while its runtime directory or bus socket is gone. `systemctl # start` on a running unit is a no-op, so a restart is the only repair, and a # restart has to begin with a stop. # # Expect this to be slow. Stopping a manager takes its containers down with it, # and a container whose PID 1 does not exit on SIGTERM is only killed at # TimeoutStopSec — 90s by default — so budget that per account. It is why only # the sweep does this and the per-command path reports instead. (EA4-319) our $user_manager_stopper = \&_stop_user_manager; sub _stop_user_manager { my ($uid) = @_; # Allowed while the template is masked: masking refuses new *starts*, not # stops. That is what lets both callers do this OUTSIDE the unmask window, # which matters because a stop can take TimeoutStopSec (90s) to return. system( "systemctl", "stop", "user\@$uid.service" ); return $? == 0; } # Is the account’s manager actually running? # # Asked instead of testing for the bus socket, because the socket outlives the # manager: logind only tears /run/user/<uid> down once the account’s LAST session # ends, so `systemctl stop user@<uid>.service` on an account that still has a # login leaves an orphaned /run/user/<uid>/bus behind. A start decision made on # `-e $bus` then skips the start and reports success while nothing is listening — # and rootless podman fails with “Failed to connect to user scope bus”. (EA4-319) our $user_manager_is_active = \&_user_manager_is_active; sub _user_manager_is_active { my ($uid) = @_; system( "systemctl", "is-active", "--quiet", "user\@$uid.service" ); return $? == 0; } # Which of the two locations `user@.service` is masked in, or undef when it is # not masked. # # Read off the filesystem rather than shelled out to # `systemctl is-enabled user@.service`. Same reasoning as $dir_linger above: it # is cheaper, it is far easier to test, and `is-enabled` only says “masked” # without saying where. Per systemd.unit(5) an empty unit file masks too, so it # counts here; a restore normalises it to the canonical symlink. sub user_manager_mask_file { for my $file ( $file_mask_etc, $file_mask_run ) { next if !lstat($file); return $file if -l _ && ( readlink($file) // '' ) eq "/dev/null"; return $file if !-l _ && -z _; } return; } # Put the mask back. Idempotent: a mask already in place is left alone, which is # what makes it safe to call on a path we never got as far as lifting. sub _restore_user_manager_mask { my ($file) = @_; return 0 if !defined $file; return 0 if $file ne $file_mask_etc && $file ne $file_mask_run; # not ours to create if ( !lstat($file) ) { # /run/systemd/system may not exist yet. mkpath dies rather than # returning false, and this sub runs on the error path, so it must not be # allowed to throw: the symlink below is the authoritative check and # reports the real errno either way. eval { path($file)->parent->mkpath; 1 }; if ( !symlink( "/dev/null", $file ) ) { # Failing to put the mask back leaves CloudLinux’s CLOS-4517 fix off # on this host, which is a good deal worse than whatever we were # doing at the time. Say so loudly, and keep $file_mask_state so the # next run through the guard tries again — unlinking it here would # strand the host unmasked with nothing left to notice. warn "ea-podman: could not put the `user\@.service` mask back at “$file”: $!\n" . "This host is left with the mask lifted. Restore it with `systemctl mask user\@.service` (`--runtime` if it was a runtime mask); ea-podman will also retry on its next run.\n"; return 0; } $daemon_reloader->(); } unlink $file_mask_state; return 1; } sub _write_mask_state { my ($file) = @_; path($file_mask_state)->spew("$file\n"); # parent is packaged, always there return; } sub _read_mask_state { return if !-e $file_mask_state; chomp( my $file = path($file_mask_state)->slurp ); # Only ever one of the two paths we mask. Anything else is not ours to # unlink or create, and this is the one place the value is trusted. return if $file ne $file_mask_etc && $file ne $file_mask_run; return $file; } # Set while a window is open, so a call inside $code does not unmask and remask # a second time — and does not deadlock: flock() is per open file description, # so a nested open of the lock file would block forever against this process’s # own lock. our $_in_window = 0; # Run $code with `user@.service` temporarily unmasked, then put the mask back # immediately. CloudLinux’s CLOS-4517 fix stays in place at rest and we bypass it # only for the operation that cannot work without it, for only as long as that # operation takes. It works because masking a unit does not stop an instance # that is already running — only new starts are refused — so a manager started # inside the window keeps running afterwards. # # * Only the manager *start* needs this. The `systemctl --user` calls # (ea_podman::util::sysctl, _systemctl_quiet) talk to the account’s # already-running manager over its own bus, where the template mask is # irrelevant; wrapping those would buy nothing and cost two # `daemon-reload`s plus a host-wide window on every container # start/stop/status. # * While the window is open the template is unmasked host-wide, not # per-account. It is short and serialized, but it cannot be made per-account # without leaving a persistent carve-out on disk, which is the thing we are # avoiding. # # See docs/container-shell-access.md “CageFS 7.6.39+ masks user@.service”. sub with_user_manager_unmasked { my ($code) = @_; if ($_in_window) { $code->(); return } # Fast path only: no lock and no daemon-reload, so a non-cagefs host pays # nothing. Another process may have the mask lifted this instant, so the # read that counts is the one under the lock below. if ( !user_manager_mask_file() && !-e $file_mask_state ) { $code->(); return } # Serialize: two accounts bootstrapping at once must not have one of them # remask while the other still needs the window open. Same # one-exclusive-flock shape as _ensure_subids() above. open my $lock_fh, ">>", $file_mask_lock or die "Could not open “$file_mask_lock”: $!\n"; flock( $lock_fh, LOCK_EX ) or die "Could not lock “$file_mask_lock”: $!\n"; # Under the lock, a state file means a predecessor was killed mid-window and # left the template unmasked — a live window holds this lock, so we could not # have got it. Its recorded mask is the one to put back on the way out, and # adopting it here (rather than restoring it now, only to lift it again two # statements later) saves a wasted symlink/daemon-reload round trip. my $file = user_manager_mask_file() // _read_mask_state(); if ( !defined $file ) { $code->(); return } _write_mask_state($file); my $bail = sub { die "ea-podman: SIG$_[0] while the `user\@.service` mask was lifted\n" }; local ( $SIG{INT}, $SIG{TERM}, $SIG{HUP} ) = ( $bail, $bail, $bail ); # The eval, not a guard object with a DESTROY: the restore below then runs on # every way out of here — normal return, a die from $code, a die from the # unlink itself, or one of the signals above. A `kill -9` is the only case # nothing in-process can cover, which is what $file_mask_state is for. eval { # Unlink the symlink ourselves rather than call `systemctl unmask`, so the # restore is byte-exact: --runtime cannot be combined with unmask # (systemctl(1)), so a mask/unmask round trip would silently relocate a # runtime mask into /etc. Already gone when we adopted a killed run’s # window, and then there is nothing to lift. if ( lstat($file) ) { unlink $file or die "Could not unmask “$file”: $!\n"; $daemon_reloader->(); } local $_in_window = 1; $code->(); 1; }; my $err = $@; _restore_user_manager_mask($file); die $err if $err; return; } # Marks a die as being about THIS account's own user session -- its own uid, its # own /run/user/<uid> -- and so safe to show the caller verbatim. A subid refusal # is not: it names /etc/subuid and the account it collided with, which is # root-side detail a cpuser must not see. The adminbin swallows everything by # default and uses these two to make the exception, so the one message written to # tell an operator which command repairs their account actually reaches them. # (EA4-319) our $session_error_prefix = "ea-podman user session: "; sub is_user_session_error { my ($err) = @_; return ( defined $err && index( $err, $session_error_prefix ) == 0 ) ? 1 : 0; } sub strip_user_session_error { my ($err) = @_; return $err if !is_user_session_error($err); substr( $err, 0, length($session_error_prefix) ) = ""; return $err; } # The one statement of what the mask is and where it comes from, so the # root-side and cpuser-side messages cannot drift apart. sub masked_user_manager_explanation { return "`user\@.service` is masked on this server, which stops any per-user systemd manager from starting. CageFS 7.6.39 and newer mask it deliberately (CloudLinux CLOS-4517) and `cagefsctl --hook-install` re-applies the mask on every cagefs install and upgrade.\n"; } # Appended to both dies below, because either can be the one that fires. # # EA4-319 open question 2 assumed only the bus would be missing, on the grounds # that user-runtime-dir@.service is not itself masked. Measured on systemd 239, # that is wrong: user@.service has Requires=user-runtime-dir@%i.service, so # masking user@ fails the whole job and the runtime dir never gets created # either — with or without a login session. The runtime-directory die is # therefore the one a masked host actually hits, which also means the original # “the runtime directory did not become available” report was accurate rather # than misleading. sub _masked_user_manager_hint { my ($uid) = @_; my $file = user_manager_mask_file() or return ""; return "\n" . masked_user_manager_explanation() . "The mask is at “$file”. ea-podman lifts it only for as long as it takes to start the account’s manager and then puts it straight back; here that bypass did not take effect.\n" . "Check `systemctl status user\@$uid.service` and `journalctl -u user\@$uid.service` for why the manager itself failed.\n"; } # The readiness poll below, as package variables so a test can exercise the # timeout without actually sleeping for it. our $poll_iterations = 100; our $poll_sleeper = sub { Time::HiRes::usleep(100_000) }; # 0.1s × 100 ≈ 10s max sub ensure_user_session { my ( $user, %opts ) = @_; # Whether this caller is allowed to restart a manager that is running but # unusable (see the die below). Defaults to off: the callers that may are the # ones that have checked there are no containers to take down with it. my $may_restart = $opts{may_restart} ? 1 : 0; my ( $uid, $gid ) = ( getpwnam($user) )[ 2, 3 ]; die "Could not look up the uid/gid for “$user”\n" if !defined $uid; # Nothing to do when the account already lingers and its manager is up — and # running enable-linger anyway is not free. systemd re-touches # $dir_linger/<user> every time, and that file’s timestamp is how we tell our # own linger from somebody else’s (see grant_covers_current_linger()). Since # this runs for every ea-podman command an account with containers makes, a # blind re-enable would age our own grant out of covering the linger it # granted, and the release on the last container would never happen. # (CPANEL-55309) # # It is also what keeps the unmask window below off the hot path entirely: a # healthy account never reaches it, so on a cagefs host we pay for the bypass # once per cold account, not once per command. # # “Its manager is up” has to be asked of systemd, not inferred from the bus # socket: the socket outlives the manager, so an account whose manager was # stopped while it still had a login keeps an orphaned # /run/user/<uid>/bus and would take this return forever — leaving podman to # fail with “Failed to connect to user scope bus” on every command, with # nothing here ever trying to repair it. It is one extra `systemctl is-active` # per command, ordered last so the three cheap checks short-circuit it, on a # path that already forks podman. (EA4-319) return if user_has_linger($user) && -d "$dir_run/$uid" && -e "$dir_run/$uid/bus" && $user_manager_is_active->($uid); mkdir $dir_run; # parent /run/user; harmless when it already exists my $rundir = "$dir_run/$uid"; my $bus = "$rundir/bus"; # Two calls, two different jobs, both refused while `user@.service` is # masked, so both go in one window: # # * enable-linger owns *persistence* — the /var/lib/systemd/linger marker, # so the account’s containers survive logout and reboot. # * the explicit start owns *up right now*, which enable-linger cannot do: # for an account that already lingers, logind will not retry a manager it # believes it already handled. That is exactly the state a cagefs host is # in after a reboot — linger marker present, no runtime dir, no bus — # where enable-linger alone is a no-op. # # The skip on the start is an “already up” shortcut, not selectivity. It asks # systemd whether the manager is running rather than testing for the bus # socket: the socket outlives the manager (see $user_manager_is_active), so # `-e $bus` would skip the start for an account whose manager is dead and # leave it dead. When the start does run, `systemctl start` blocks until the # job settles, which is what lets the window close before the poll below # rather than around it. (EA4-319) my $start_failed; with_user_manager_unmasked( sub { # Re-enabling for an account we already hold a grant on moves systemd’s # marker ahead of that grant, so the grant has to move with it or it stops # covering the very linger it is for. Recorded around the enable, not after # the readiness poll below, which can die. my $regrant = user_has_granted_linger($user); $linger_enabler->($user); record_linger_grant($user) if $regrant; $start_failed = !$user_manager_starter->($uid) if !$user_manager_is_active->($uid); return; } ); # Deliberately OUTSIDE the window. The mask only refuses new *starts* of # user@.service; waiting for a socket to appear under /run/user/<uid> touches # nothing it gates. Polling inside would hold the host-wide unmask window and # the lock for up to the full ceiling, serializing every other account behind # one slow bootstrap. # # The readiness signal `systemctl --user` and rootless podman actually need is # the manager’s dbus socket, not the directory: the directory appears well # before the manager is up, so polling only for it races and leaves podman # with “Failed to connect to user scope bus”. if ( !$start_failed ) { for ( 1 .. $poll_iterations ) { last if -d $rundir && -e $bus; $poll_sleeper->(); } } # A manager that is still running with its runtime directory or bus gone is a # state this path cannot repair, and must not try to. The start above is # skipped for an active manager -- and `systemctl start` on one is a no-op # anyway -- so the poll has just waited out its whole ceiling for a socket # nothing was ever going to create, and every later command for this account # will do the same. Only a restart fixes it. # # Deliberately not restarted here. ea_podman::util::init_user() reaches this # for every verb, including read-only ones, so repairing would mean an # `ea-podman list` taking the account's containers down for as long as # TimeoutStopSec allows -- a worse outcome than the fault it repairs, and one # the caller never asked for. The sweep does restart it (see # ensure_user_sessions below), because boot and an explicit admin invocation # are the two contexts where that is expected. So: name the condition, and # name the command whose job it is. # # Checked after the poll rather than before it, so a manager that another # process started a moment ago still gets its ceiling to finish coming up. # (EA4-319) if ( ( !-d $rundir || !-e $bus ) && $user_manager_is_active->($uid) ) { # Nothing to lose: with no containers under it, restarting the manager # costs no downtime, so repair it here rather than making the caller do # it. This is the reachable case -- a failed install releases the session # it just granted (ea_podman::util::install_container), which can leave # the account wedged with zero containers, and the sweep works from the # registry so it would never come back to it. if ($may_restart) { $user_manager_stopper->($uid); my $restarted; with_user_manager_unmasked( sub { $restarted = $user_manager_starter->($uid); return } ); if ($restarted) { for ( 1 .. $poll_iterations ) { last if -d $rundir && -e $bus; $poll_sleeper->(); } } } if ( ( !-d $rundir || !-e $bus ) && $user_manager_is_active->($uid) ) { my $what = !-d $rundir ? "its runtime directory “$rundir” is gone" : "its session bus “$bus” is gone, so nothing is listening on it"; die $session_error_prefix . "The user systemd manager for “$user” (uid $uid) is running, but $what.\n" . "A manager cannot recreate its own runtime directory or socket, so this does not heal on its own: it happens when /run/user/$uid is torn down underneath a manager that is still up.\n" . "Repair it as root with `systemctl stop user\@$uid.service` and then re-run this command; the manager is started fresh. This stops the account’s containers, which come back with it.\n" . "`ea-podman ensure_user_sessions` does the same for every account the container registry lists — but not for an account with no containers, which is how this state is usually reached.\n"; } } if ( !-d $rundir ) { die $session_error_prefix . "The directory “$rundir” is missing: neither `loginctl enable-linger $user` nor `systemctl start user\@$uid.service` produced it.\n" . _masked_user_manager_hint($uid); } if ( !-e $bus ) { die $session_error_prefix . "The user session bus “$bus” did not appear after `loginctl enable-linger $user` and `systemctl start user\@$uid.service` (the user systemd manager did not start).\n" . _masked_user_manager_hint($uid); } return; } # The boot-time counterpart to ensure_user_session(): bring up the managers for # a whole list of accounts in one sweep. Driven by `ea-podman # ensure_user_sessions`, which the ea-podman-user-managers.service unit runs at # boot — the trigger EA4-319 was missing, since nothing else invokes the unmask # window at boot and logind will not start a masked `user@.service` for a # lingering account on its own. # # Deliberately NOT a loop over ensure_user_session(), for two reasons that pull # against each other and only bite at this scale: # # * One window for the whole host, not one per account. $_in_window already # makes a nested call reuse an open window, so a loop *inside* one # with_user_manager_unmasked() would get that much right on its own: one # unmask/remask pair and two daemon-reloads for the sweep instead of 2N. # * But that same loop would drag every account’s readiness poll INSIDE the # window, and that poll is outside it on purpose (see ensure_user_session # above): its ceiling is ~10s per account, so on a box with hundreds of # accounts the host-wide unmask would be held open for the entire sweep. # Exactly backwards from “as short as we can make it”. # # So the phases are split by hand. Every start happens in one window — each # blocks until its job settles, which is what keeps the window short — then the # window closes and the buses are polled *together*, one ceiling for the sweep # rather than one per account. # # Warns and carries on per account rather than dying: one account that cannot # start its manager must not cost every other account on the box its containers, # and must not abort the sweep with the mask half-restored. Returns a hashref of # user => "ok" (already up), "started", "failed", or "unknown" (no such user). sub ensure_user_sessions { my (@users) = @_; my %result; my @pending; mkdir $dir_run; # parent /run/user; harmless when it already exists for my $user (@users) { my $uid = ( getpwnam($user) )[2]; if ( !defined $uid ) { # An account in the registry that no longer exists on the box. Not # fatal, and not this sweep’s business to clean up. warn "ea-podman: no such user “$user”; skipping\n"; $result{$user} = "unknown"; next; } # The reason a non-cagefs host pays nothing here: after a normal boot # logind has already started every lingering account’s manager, so every # account is healthy, @pending is empty, and no window is ever opened. # # Stricter than ensure_user_session()’s early return, which stops at the # bus socket: that one is on the hot path of every ea-podman command and # cannot afford a `systemctl is-active` per call. This runs once at boot, # so it can afford to ask systemd rather than trust a socket that outlives # the manager it belongs to (see $user_manager_is_active). if ( user_has_linger($user) && -d "$dir_run/$uid" && -e "$dir_run/$uid/bus" && $user_manager_is_active->($uid) ) { $result{$user} = "ok"; next; } push @pending, { user => $user, uid => $uid }; } return \%result if !@pending; # Stop the unusable managers BEFORE opening the window, not inside it. # # “Active” is not “usable”: a manager whose /run/user/<uid> was torn down # beneath it keeps running with no socket to talk to and cannot recreate one, # and `systemctl start` on a running unit is a no-op, so the only repair is a # restart. The sweep is where a restart belongs -- it runs at boot and from an # explicit admin invocation, both contexts where taking the account's # containers down is expected. Every other path reports instead; see # ensure_user_session() above. # # Out here because a stop is slow and needs no window. It takes the account's # containers with it, and a container whose PID 1 ignores SIGTERM is only # killed at TimeoutStopSec -- 90s each, measured. Inside the window that would # hold the host-wide unmask open for minutes on a box with several such # accounts, which is the very thing the phase split above exists to avoid. # Masking refuses new *starts*, not stops, so nothing here needs the template # lifted. (EA4-319) for my $acct (@pending) { my $uid = $acct->{uid}; next if !$user_manager_is_active->($uid); next if -e "$dir_run/$uid/bus"; $user_manager_stopper->($uid); } with_user_manager_unmasked( sub { for my $acct (@pending) { my ( $user, $uid ) = @{$acct}{qw(user uid)}; # Contained per account: a die here would unwind out of the # window, restoring the mask with accounts still unstarted. local $@; eval { # Same regrant bookkeeping as ensure_user_session(): a # re-enable moves systemd’s linger marker ahead of our grant, # so the grant has to move with it or it stops covering the # very linger it is for. (CPANEL-55309) my $regrant = user_has_granted_linger($user); $linger_enabler->($user); record_linger_grant($user) if $regrant; # Anything still active here is genuinely usable: the # unusable ones were stopped in the pre-pass above, so this # is the same “already up” shortcut as ever. $acct->{start_failed} = !$user_manager_starter->($uid) if !$user_manager_is_active->($uid); 1; } or do { warn "ea-podman: could not start the user systemd manager for “$user”: $@"; $acct->{start_failed} = 1; }; } return; } ); # Outside the window, and shared across accounts: the starts above already # blocked until their jobs settled, so this is the tail of a race we have # mostly won already. An account whose start outright failed is not waited # for at all, same as ensure_user_session(). my @waiting = grep { !$_->{start_failed} } @pending; for ( 1 .. $poll_iterations ) { @waiting = grep { !( -d "$dir_run/$_->{uid}" && -e "$dir_run/$_->{uid}/bus" ) } @waiting; last if !@waiting; $poll_sleeper->(); } for my $acct (@pending) { my ( $user, $uid ) = @{$acct}{qw(user uid)}; my $rundir = "$dir_run/$uid"; # The manager, not just the socket: an orphaned bus left behind by a # stopped manager would otherwise be reported as a success. if ( -d $rundir && -e "$rundir/bus" && $user_manager_is_active->($uid) ) { $result{$user} = "started"; next; } $result{$user} = "failed"; # The same symptoms ensure_user_session() dies on, and the same hint — # which names the mask when there is one. A warn, not a die: see above. my $why = !-d $rundir ? "the runtime directory “$rundir” was never created" : !-e "$rundir/bus" ? "the user session bus “$rundir/bus” never appeared" : "the session bus “$rundir/bus” exists but `user\@$uid.service` is not running, so nothing is listening on it"; warn "ea-podman: the user systemd manager for “$user” (uid $uid) did not come up: $why.\n" . _masked_user_manager_hint($uid); } return \%result; } # The counterpart to $linger_enabler: `loginctl disable-linger <user>`, run as # root, stops the user’s systemd manager and lets logind tear down # /run/user/<uid> once the account has no session left. Held in a package # variable so tests can stub the privileged call. (CPANEL-55309) our $linger_disabler = \&_disable_linger; sub _disable_linger { my ($user) = @_; system( "loginctl", "disable-linger", $user ); return $? == 0; } # The marker paths below interpolate an account name and two are unlink()ed as # root. Anything implausible reads as “no such user”, safe everywhere here. sub _is_valid_linger_user { my ($user) = @_; return 0 if !defined $user; return $user =~ m{\A[a-z0-9][a-z0-9._-]*\z}i ? 1 : 0; } sub user_has_linger { my ($user) = @_; return 0 if !_is_valid_linger_user($user); return -e "$dir_linger/$user" ? 1 : 0; } # The grant record (see $dir_granted_linger): idempotent, root only, and “no # record” is always the safe answer — without one nothing takes an account’s # linger away. See ea_podman::util::_user_session_is_releasable(). sub user_has_granted_linger { my ($user) = @_; return 0 if !_is_valid_linger_user($user); return -e "$dir_granted_linger/$user" ? 1 : 0; } # Always re-touched, never skipped: the mtime has to track the enable-linger # this call is recording. See grant_covers_current_linger(). sub record_linger_grant { my ($user) = @_; return 0 if !_is_valid_linger_user($user) || $user eq "root"; mkdir( $dir_granted_linger, 0700 ); # the parent is packaged; harmless when it already exists chmod( 0700, $dir_granted_linger ); local $@; eval { path("$dir_granted_linger/$user")->touch; 1 } or do { warn "Could not record the linger grant for “$user”: $@"; return 0; }; return 1; } sub revoke_linger_grant { my ($user) = @_; return 1 if !user_has_granted_linger($user); return unlink("$dir_granted_linger/$user") ? 1 : 0; } # A record says we granted *a* linger; this says whether it is the current one. # We record just after enable-linger, so ours is never the older of the two — a # newer systemd marker means somebody else enabled this linger after ours went # away. Unreadable either way ➜ no, same as a missing record. (CPANEL-55309) sub grant_covers_current_linger { my ($user) = @_; return 0 if !user_has_granted_linger($user) || !user_has_linger($user); my $granted_at = ( stat("$dir_granted_linger/$user") )[9]; my $lingering_since = ( stat("$dir_linger/$user") )[9]; return 0 if !defined $granted_at || !defined $lingering_since; return $lingering_since <= $granted_at ? 1 : 0; } # Undo what ensure_user_session() set up. Idempotent: a no-op (and a “success”) # when the user is not lingering in the first place. Deciding that a user no # longer needs a rootless session is the caller’s job — see # ea_podman::util::release_user_session(). (CPANEL-55309) sub remove_user_session { my ($user) = @_; return 1 if !user_has_linger($user); $linger_disabler->($user); # `loginctl disable-linger` can exit non-zero for reasons that leave the # linger correctly off (a stopped manager, for instance), so trust the # marker over the exit code. return user_has_linger($user) ? 0 : 1; } # `loginctl disable-linger` has no user to look up once an account has been # deleted, but logind’s marker file outlives the account — and would silently # linger any future account that reuses the name. Dropping the marker is # precisely what disable-linger itself does. (CPANEL-55309) sub remove_stale_linger_marker { my ($user) = @_; return 1 if !user_has_linger($user); return unlink("$dir_linger/$user") ? 1 : 0; } sub assert_has_user_namespaces { my ($verbose) = @_; chomp( my $max_uns = `sysctl --values user.max_user_namespaces 2>/dev/null` ); if ( !$max_uns ) { my $c7_msg = <<'C7'; • On CentOS 7 running these command enable user namespaces: 1. grubby --args="namespce.unpriv_enable=1 user_namespace.enable=1" --update-kernel="$(grubby --default-kernel)" 2. echo "user.max_user_namespaces=15076" >> /etc/sysctl.conf 3. reboot C7 chomp($c7_msg); # I wish there was a better way … my $os = -f '/etc/os-release' ? `source /etc/os-release; echo \$ID\$VERSION_ID` : "??"; chomp($os); $c7_msg = "" if $os ne "centos7"; die <<"END_NO_UNS"; $bad User Namespaces not available (`sysctl --values user.max_user_namespaces`): • Container based packages will not work until they are. • To learn more read `man user_namespaces`$c7_msg END_NO_UNS } print "$good user.max_user_namespaces = '$max_uns'\n" if $verbose; return $max_uns; } sub get_subuids { return _parse_subid_file($file_subuid); } sub get_subgids { return _parse_subid_file($file_subgid); } # Every account in the file whose range is not exclusively its own, as # user => why. These are the accounts _ensure_subids() refuses to act for, asked # of the whole file at once so a box can be audited up front. sub get_subuid_problems { return _find_range_problems($file_subuid); } sub get_subgid_problems { return _find_range_problems($file_subgid); } ############### #### helpers ## ############### # Every well-formed allocation in $file, in file order, as # { user => …, start => …, count => … }. # # _parse_subid_file() keeps one entry per account, which is all its callers want # to display; allocation needs every line, since a duplicate’s IDs are just as # taken as any other’s. # # Plain open rather than Path::Tiny: its readers take a shared flock, which # would block on the exclusive lock _ensure_subids() already holds on this file. sub _read_ranges { my ($file) = @_; # A file that does not exist has nothing allocated in it. Anything else has # to be fatal: treating an unreadable file as empty would hand out IDs an # account already holds. open my $fh, "<", $file or do { return [] if !-e $file; die "Could not read “$file”: $!\n"; }; my @ranges; while ( my $line = readline $fh ) { chomp $line; next if $line !~ m/\S/; my ( $user, $ranges ) = split( ":", $line, 2 ); my ( $start, $count ) = _parse_range($ranges); # A line this cannot parse is one it cannot reason about. Skipping it # keeps a hand-added comment from taking a working account offline; its # IDs going uncounted is why allocations are overlap-checked. next if !defined $count; push @ranges, { user => $user, start => $start, count => $count }; } return \@ranges; } # The “<start>:<count>” half of a subid line, and only when it is one: a # non-numeric, empty or zero-count range cannot take part in an overlap. sub _parse_range { my ($ranges) = @_; return if !defined $ranges; my ( $start, $count ) = split( /:/, $ranges ); return if !defined $start || !defined $count; return if $start !~ m/\A[0-9]+\z/ || $count !~ m/\A[0-9]+\z/; return if $count == 0; return ( $start, $count ); } sub _user_ranges { my ( $ranges_ar, $user ) = @_; return [ grep { $_->{user} eq $user } @{$ranges_ar} ]; } # The first account holding IDs inside [$start, $start + $count - 1], ignoring # $skip_user’s own entries. sub _find_overlap { my ( $ranges_ar, $start, $count, $skip_user ) = @_; my $end = $start + $count - 1; for my $range ( @{$ranges_ar} ) { next if defined $skip_user && $range->{user} eq $skip_user; my $other_end = $range->{start} + $range->{count} - 1; return $range->{user} if $start <= $other_end && $range->{start} <= $end; } return; } # Two accounts sharing host IDs is the breach these ranges exist to prevent, so # both of these are hard errors. Reallocating automatically is not an option — # the container files on disk are owned by the old range’s IDs — so it takes an # administrator, which is what the messages say. sub _assert_allocation_is_free { my ( $user, $start, $count, $ranges_ar, $file ) = @_; my $overlaps = _find_overlap( $ranges_ar, $start, $count, $user ); return if !defined $overlaps; my $end = $start + $count - 1; die "Refusing to give “$user” the host IDs $start-$end in “$file”: they overlap the IDs “$overlaps” already holds. An administrator needs to sort out “$file” before “$user” can run containers.\n"; } sub _assert_range_is_exclusive { my ( $user, $ranges_ar, $file ) = @_; my $mine = _user_ranges( $ranges_ar, $user ); if ( @{$mine} > 1 ) { die "“$user” has more than one range in “$file”, so which host IDs are theirs is ambiguous. An administrator needs to leave them exactly one.\n"; } my ( $start, $count ) = ( $mine->[0]{start}, $mine->[0]{count} ); my $overlaps = _find_overlap( $ranges_ar, $start, $count, $user ); return if !defined $overlaps; my $end = $start + $count - 1; die "“$user” shares the host IDs $start-$end with “$overlaps” in “$file”, so their containers are not isolated from each other’s. An administrator needs to give one of them a range of its own — `ea-podman subids` lists every account this affects.\n"; } sub _find_range_problems { my ($file) = @_; my $ranges_ar = _read_ranges($file); my %problem; # More than one line: which host IDs are actually theirs is ambiguous, so it # needs fixing whether or not the lines overlap anything. my %lines; $lines{ $_->{user} }++ for @{$ranges_ar}; $problem{$_} = "is listed with more than one range" for grep { $lines{$_} > 1 } keys %lines; # Overlaps, by sweeping in start order against the ranges still open at each # start. Every partner is named, not just the one reaching furthest: an # administrator handed a partial list has no way to tell it is partial, and # would have to re-audit after each edit to find the next name. # # @open is pruned to the ranges that reach the current start, so on a healthy # file it holds at most the previous range and the sweep stays linear; it # only grows where ranges genuinely pile up, which is the broken case worth # spending the comparisons on. my %shared_with; my @open; for my $range ( sort { $a->{start} <=> $b->{start} || $a->{user} cmp $b->{user} } @{$ranges_ar} ) { # Ending before this one starts means ending before every later one # starts too, so it is done being compared. @open = grep { $_->{start} + $_->{count} - 1 >= $range->{start} } @open; # Everything left starts at or before this range and reaches into it. for my $other (@open) { $shared_with{ $range->{user} }{ $other->{user} } = 1; $shared_with{ $other->{user} }{ $range->{user} } = 1; } push @open, $range; } # Sharing IDs with another account is the more urgent of the two, so it is # what gets reported for an account with both problems. Overlapping only # itself means two lines, which the ambiguity message above already covers. for my $user ( keys %shared_with ) { my @others = grep { $_ ne $user } sort keys %{ $shared_with{$user} }; next if !@others; $problem{$user} = "shares host IDs with " . join( ", ", map { "“$_”" } @others ); } return \%problem; } sub _parse_subid_file { my ($file) = @_; my $hr = {}; for my $line ( path($file)->lines( { chomp => 1 } ) ) { my ( $user, $ranges ) = split( ":", $line, 2 ); warn "“$user” is in “$file” more than once!\n" if exists $hr->{$user}; $hr->{$user} = $ranges; } return $hr; } sub _ensure_storage_conf { # This is only necessary on certain OS's. # UGMO: # Since we can only extend Cpanel::OS for new versions of ULC we can’t use a proper OS agnostic attribute like `if (Cpanel::OS::container_storage_overlay_ignore_chown_errors) { ` # That being the case we have to violate the point of Cpanel::OS and do an isolated one off here :/ if ( Cpanel::OS::distro() eq "ubuntu" && Cpanel::OS::major() eq "22" ) { my $conf = path('/etc/containers/storage.conf'); if ( !$conf->exists() ) { $conf->spew( qq{[storage] driver = "overlay" runroot = "/run/containers/storage" graphroot = "/var/lib/containers/storage" [storage.options] ignore_chown_errors = "true" } ); } } return; } 1;
💾 保存文件
← 返回文件管理器