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