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