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