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");