at 25.11-pre 14 kB view raw
1use strict; 2use warnings; 3use File::Path qw(make_path); 4use File::Slurp; 5use Getopt::Long; 6use JSON; 7use Time::Piece; 8 9# Keep track of deleted uids and gids. 10my $uidMapFile = "/var/lib/nixos/uid-map"; 11my $uidMap = -e $uidMapFile ? decode_json(read_file($uidMapFile)) : {}; 12 13my $gidMapFile = "/var/lib/nixos/gid-map"; 14my $gidMap = -e $gidMapFile ? decode_json(read_file($gidMapFile)) : {}; 15 16my $is_dry = ($ENV{'NIXOS_ACTION'} // "") eq "dry-activate"; 17GetOptions("dry-activate" => \$is_dry); 18make_path("/var/lib/nixos", { mode => 0755 }) unless $is_dry; 19 20sub updateFile { 21 my ($path, $contents, $perms) = @_; 22 return if $is_dry; 23 write_file($path, { atomic => 1, binmode => ':utf8', perms => $perms // 0644 }, $contents) or die; 24} 25 26# Converts an ISO date to number of days since 1970-01-01 27sub dateToDays { 28 my ($date) = @_; 29 my $time = Time::Piece->strptime($date, "%Y-%m-%d"); 30 return $time->epoch / 60 / 60 / 24; 31} 32 33sub nscdInvalidate { 34 system("nscd", "--invalidate", $_[0]) unless $is_dry; 35} 36 37sub hashPassword { 38 my ($password) = @_; 39 my $salt = ""; 40 my @chars = ('.', '/', 0..9, 'A'..'Z', 'a'..'z'); 41 $salt .= $chars[rand 64] for (1..8); 42 return crypt($password, '$6$' . $salt . '$'); 43} 44 45sub dry_print { 46 if ($is_dry) { 47 print STDERR ("$_[1] $_[2]\n") 48 } else { 49 print STDERR ("$_[0] $_[2]\n") 50 } 51} 52 53 54# Functions for allocating free GIDs/UIDs. FIXME: respect ID ranges in 55# /etc/login.defs. 56sub allocId { 57 my ($used, $prevUsed, $idMin, $idMax, $delta, $getid) = @_; 58 my $id = $delta > 0 ? $idMin : $idMax; 59 while ($id >= $idMin && $id <= $idMax) { 60 if (!$used->{$id} && !$prevUsed->{$id} && !defined &$getid($id)) { 61 $used->{$id} = 1; 62 return $id; 63 } 64 $id += $delta; 65 } 66 die "$0: out of free UIDs or GIDs\n"; 67} 68 69my (%gidsUsed, %uidsUsed, %gidsPrevUsed, %uidsPrevUsed); 70 71sub allocGid { 72 my ($name) = @_; 73 my $prevGid = $gidMap->{$name}; 74 if (defined $prevGid && !defined $gidsUsed{$prevGid}) { 75 dry_print("reviving", "would revive", "group '$name' with GID $prevGid"); 76 $gidsUsed{$prevGid} = 1; 77 return $prevGid; 78 } 79 return allocId(\%gidsUsed, \%gidsPrevUsed, 400, 999, -1, sub { my ($gid) = @_; getgrgid($gid) }); 80} 81 82sub allocUid { 83 my ($name, $isSystemUser) = @_; 84 my ($min, $max, $delta) = $isSystemUser ? (400, 999, -1) : (1000, 29999, 1); 85 my $prevUid = $uidMap->{$name}; 86 if (defined $prevUid && $prevUid >= $min && $prevUid <= $max && !defined $uidsUsed{$prevUid}) { 87 dry_print("reviving", "would revive", "user '$name' with UID $prevUid"); 88 $uidsUsed{$prevUid} = 1; 89 return $prevUid; 90 } 91 return allocId(\%uidsUsed, \%uidsPrevUsed, $min, $max, $delta, sub { my ($uid) = @_; getpwuid($uid) }); 92} 93 94# Read the declared users/groups 95my $spec = decode_json(read_file($ARGV[0])); 96 97# Don't allocate UIDs/GIDs that are manually assigned. 98foreach my $g (@{$spec->{groups}}) { 99 $gidsUsed{$g->{gid}} = 1 if defined $g->{gid}; 100} 101 102foreach my $u (@{$spec->{users}}) { 103 $uidsUsed{$u->{uid}} = 1 if defined $u->{uid}; 104} 105 106# Likewise for previously used but deleted UIDs/GIDs. 107$uidsPrevUsed{$_} = 1 foreach values %{$uidMap}; 108$gidsPrevUsed{$_} = 1 foreach values %{$gidMap}; 109 110 111# Read the current /etc/group. 112sub parseGroup { 113 chomp; 114 my @f = split(':', $_, -4); 115 my $gid = $f[2] eq "" ? undef : int($f[2]); 116 $gidsUsed{$gid} = 1 if defined $gid; 117 return ($f[0], { name => $f[0], password => $f[1], gid => $gid, members => $f[3] }); 118} 119 120my %groupsCur = -f "/etc/group" ? map { parseGroup } read_file("/etc/group", { binmode => ":utf8" }) : (); 121 122# Read the current /etc/passwd. 123sub parseUser { 124 chomp; 125 my @f = split(':', $_, -7); 126 my $uid = $f[2] eq "" ? undef : int($f[2]); 127 $uidsUsed{$uid} = 1 if defined $uid; 128 return ($f[0], { name => $f[0], fakePassword => $f[1], uid => $uid, 129 gid => $f[3], description => $f[4], home => $f[5], shell => $f[6] }); 130} 131my %usersCur = -f "/etc/passwd" ? map { parseUser } read_file("/etc/passwd", { binmode => ":utf8" }) : (); 132 133# Read the groups that were created declaratively (i.e. not by groups) 134# in the past. These must be removed if they are no longer in the 135# current spec. 136my $declGroupsFile = "/var/lib/nixos/declarative-groups"; 137my %declGroups; 138$declGroups{$_} = 1 foreach split / /, -e $declGroupsFile ? read_file($declGroupsFile, { binmode => ":utf8" }) : ""; 139 140# Idem for the users. 141my $declUsersFile = "/var/lib/nixos/declarative-users"; 142my %declUsers; 143$declUsers{$_} = 1 foreach split / /, -e $declUsersFile ? read_file($declUsersFile, { binmode => ":utf8" }) : ""; 144 145 146# Generate a new /etc/group containing the declared groups. 147my %groupsOut; 148foreach my $g (@{$spec->{groups}}) { 149 my $name = $g->{name}; 150 my $existing = $groupsCur{$name}; 151 152 my %members = map { ($_, 1) } @{$g->{members}}; 153 154 if (defined $existing) { 155 $g->{gid} = $existing->{gid} if !defined $g->{gid}; 156 if ($g->{gid} != $existing->{gid}) { 157 dry_print("warning: not applying", "warning: would not apply", "GID change of group ‘$name’ ($existing->{gid} -> $g->{gid}) in /etc/group"); 158 $g->{gid} = $existing->{gid}; 159 } 160 $g->{password} = $existing->{password}; # do we want this? 161 if ($spec->{mutableUsers}) { 162 # Merge in non-declarative group members. 163 foreach my $uname (split /,/, $existing->{members} // "") { 164 $members{$uname} = 1 if !defined $declUsers{$uname}; 165 } 166 } 167 } else { 168 $g->{gid} = allocGid($name) if !defined $g->{gid}; 169 $g->{password} = "x"; 170 } 171 172 $g->{members} = join ",", sort(keys(%members)); 173 $groupsOut{$name} = $g; 174 175 $gidMap->{$name} = $g->{gid}; 176} 177 178# Update the persistent list of declarative groups. 179updateFile($declGroupsFile, join(" ", sort(keys %groupsOut))); 180 181# Merge in the existing /etc/group. 182foreach my $name (keys %groupsCur) { 183 my $g = $groupsCur{$name}; 184 next if defined $groupsOut{$name}; 185 if (!$spec->{mutableUsers} || defined $declGroups{$name}) { 186 dry_print("removing group", "would remove group", "‘$name’"); 187 } else { 188 $groupsOut{$name} = $g; 189 } 190} 191 192 193# Rewrite /etc/group. FIXME: acquire lock. 194my @lines = map { join(":", $_->{name}, $_->{password}, $_->{gid}, $_->{members}) . "\n" } 195 (sort { $a->{gid} <=> $b->{gid} } values(%groupsOut)); 196updateFile($gidMapFile, to_json($gidMap, {canonical => 1})); 197updateFile("/etc/group", \@lines); 198nscdInvalidate("group"); 199 200# Generate a new /etc/passwd containing the declared users. 201my %usersOut; 202foreach my $u (@{$spec->{users}}) { 203 my $name = $u->{name}; 204 205 # Resolve the gid of the user. 206 if ($u->{group} =~ /^[0-9]$/) { 207 $u->{gid} = $u->{group}; 208 } elsif (defined $groupsOut{$u->{group}}) { 209 $u->{gid} = $groupsOut{$u->{group}}->{gid} // die; 210 } else { 211 warn "warning: user ‘$name’ has unknown group ‘$u->{group}’\n"; 212 $u->{gid} = 65534; 213 } 214 215 my $existing = $usersCur{$name}; 216 if (defined $existing) { 217 $u->{uid} = $existing->{uid} if !defined $u->{uid}; 218 if ($u->{uid} != $existing->{uid}) { 219 dry_print("warning: not applying", "warning: would not apply", "UID change of user ‘$name’ ($existing->{uid} -> $u->{uid}) in /etc/passwd"); 220 $u->{uid} = $existing->{uid}; 221 } 222 } else { 223 $u->{uid} = allocUid($name, $u->{isSystemUser}) if !defined $u->{uid}; 224 225 if (!defined $u->{hashedPassword}) { 226 if (defined $u->{initialPassword}) { 227 $u->{hashedPassword} = hashPassword($u->{initialPassword}); 228 } elsif (defined $u->{initialHashedPassword}) { 229 $u->{hashedPassword} = $u->{initialHashedPassword}; 230 } 231 } 232 } 233 234 # Ensure home directory incl. ownership and permissions. 235 if ($u->{createHome} and !$is_dry) { 236 make_path($u->{home}, { mode => 0755 }) if ! -e $u->{home}; 237 chown $u->{uid}, $u->{gid}, $u->{home}; 238 chmod oct($u->{homeMode}), $u->{home}; 239 } 240 241 if (defined $u->{hashedPasswordFile}) { 242 if (-e $u->{hashedPasswordFile}) { 243 $u->{hashedPassword} = read_file($u->{hashedPasswordFile}); 244 chomp $u->{hashedPassword}; 245 } else { 246 warn "warning: password file ‘$u->{hashedPasswordFile}’ does not exist\n"; 247 } 248 } elsif (defined $u->{password}) { 249 $u->{hashedPassword} = hashPassword($u->{password}); 250 } 251 252 if (!defined $u->{shell}) { 253 if (defined $existing) { 254 $u->{shell} = $existing->{shell}; 255 } else { 256 warn "warning: no declarative or previous shell for ‘$name’, setting shell to nologin\n"; 257 $u->{shell} = "/run/current-system/sw/bin/nologin"; 258 } 259 } 260 261 $u->{fakePassword} = $existing->{fakePassword} // "x"; 262 $usersOut{$name} = $u; 263 264 $uidMap->{$name} = $u->{uid}; 265} 266 267# Update the persistent list of declarative users. 268updateFile($declUsersFile, join(" ", sort(keys %usersOut))); 269 270# Merge in the existing /etc/passwd. 271foreach my $name (keys %usersCur) { 272 my $u = $usersCur{$name}; 273 next if defined $usersOut{$name}; 274 if (!$spec->{mutableUsers} || defined $declUsers{$name}) { 275 dry_print("removing user", "would remove user", "‘$name’"); 276 } else { 277 $usersOut{$name} = $u; 278 } 279} 280 281# Rewrite /etc/passwd. FIXME: acquire lock. 282@lines = map { join(":", $_->{name}, $_->{fakePassword}, $_->{uid}, $_->{gid}, $_->{description}, $_->{home}, $_->{shell}) . "\n" } 283 (sort { $a->{uid} <=> $b->{uid} } (values %usersOut)); 284updateFile($uidMapFile, to_json($uidMap, {canonical => 1})); 285updateFile("/etc/passwd", \@lines); 286nscdInvalidate("passwd"); 287 288 289# Rewrite /etc/shadow to add new accounts or remove dead ones. 290my @shadowNew; 291my %shadowSeen; 292 293foreach my $line (-f "/etc/shadow" ? read_file("/etc/shadow", { binmode => ":utf8" }) : ()) { 294 chomp $line; 295 # struct name copied from `man 3 shadow` 296 my ($sp_namp, $sp_pwdp, $sp_lstch, $sp_min, $sp_max, $sp_warn, $sp_inact, $sp_expire, $sp_flag) = split(':', $line, -9); 297 my $u = $usersOut{$sp_namp};; 298 next if !defined $u; 299 $sp_pwdp = "!" if !$spec->{mutableUsers}; 300 $sp_pwdp = $u->{hashedPassword} if defined $u->{hashedPassword} && !$spec->{mutableUsers}; # FIXME 301 $sp_expire = dateToDays($u->{expires}) if defined $u->{expires}; 302 chomp $sp_pwdp; 303 push @shadowNew, join(":", $sp_namp, $sp_pwdp, $sp_lstch, $sp_min, $sp_max, $sp_warn, $sp_inact, $sp_expire, $sp_flag) . "\n"; 304 $shadowSeen{$sp_namp} = 1; 305} 306 307foreach my $u (values %usersOut) { 308 next if defined $shadowSeen{$u->{name}}; 309 my $hashedPassword = "!"; 310 $hashedPassword = $u->{hashedPassword} if defined $u->{hashedPassword}; 311 my $expires = ""; 312 $expires = dateToDays($u->{expires}) if defined $u->{expires}; 313 # FIXME: set correct value for sp_lstchg. 314 push @shadowNew, join(":", $u->{name}, $hashedPassword, "1::::", $expires, "") . "\n"; 315} 316 317updateFile("/etc/shadow", \@shadowNew, 0640); 318{ 319 my $uid = getpwnam "root"; 320 my $gid = getgrnam "shadow"; 321 my $path = "/etc/shadow"; 322 (chown($uid, $gid, $path) || die "Failed to change ownership of $path: $!") unless $is_dry; 323} 324 325# Rewrite /etc/subuid & /etc/subgid to include default container mappings 326 327my $subUidMapFile = "/var/lib/nixos/auto-subuid-map"; 328my $subUidMap = -e $subUidMapFile ? decode_json(read_file($subUidMapFile)) : {}; 329 330my (%subUidsUsed, %subUidsPrevUsed); 331 332$subUidsPrevUsed{$_} = 1 foreach values %{$subUidMap}; 333 334sub allocSubUid { 335 my ($name, @rest) = @_; 336 337 # TODO: No upper bounds? 338 my ($min, $max, $delta) = (100000, 100000 + 100 * 65536, 65536); 339 my $prevId = $subUidMap->{$name}; 340 if (defined $prevId && !defined $subUidsUsed{$prevId}) { 341 $subUidsUsed{$prevId} = 1; 342 return $prevId; 343 } 344 345 return allocId(\%subUidsUsed, \%subUidsPrevUsed, $min, $max, $delta, sub { undef }); 346} 347 348my @subGids; 349my @subUids; 350foreach my $u (values %usersOut) { 351 my $name = $u->{name}; 352 353 foreach my $range (@{$u->{subUidRanges}}) { 354 my $value = join(":", ($name, $range->{startUid}, $range->{count})); 355 push @subUids, $value; 356 } 357 358 foreach my $range (@{$u->{subGidRanges}}) { 359 my $value = join(":", ($name, $range->{startGid}, $range->{count})); 360 push @subGids, $value; 361 } 362 363 if($u->{autoSubUidGidRange}) { 364 my $subordinate = allocSubUid($name); 365 if (defined $subUidMap->{$name} && $subUidMap->{$name} != $subordinate) { 366 print STDERR "warning: The subuids for '$name' changed, as they coincided with the subuids of a different user (see /etc/subuid). " 367 . "The range now starts with $subordinate instead of $subUidMap->{$name}. " 368 . "If the subuids were used (e.g. with rootless container managers like podman), please change the ownership of affected files accordingly. " 369 . "Alternatively, to keep the old overlapping ranges, add this to the system configuration: " 370 . "users.users.$name.subUidRanges = [{startUid = $subUidMap->{$name}; count = 65536;}]; " 371 . "users.users.$name.subGidRanges = [{startGid = $subUidMap->{$name}; count = 65536;}];\n"; 372 } 373 $subUidMap->{$name} = $subordinate; 374 my $value = join(":", ($name, $subordinate, 65536)); 375 push @subUids, $value; 376 push @subGids, $value; 377 } 378} 379 380updateFile("/etc/subuid", join("\n", @subUids) . "\n"); 381updateFile("/etc/subgid", join("\n", @subGids) . "\n"); 382updateFile($subUidMapFile, to_json($subUidMap) . "\n");