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.tmp", { binmode => ':utf8', perms => $perms // 0644 }, $contents);
20 rename("$path.tmp", $path) or die;
21}
22
23
24sub hashPassword {
25 my ($password) = @_;
26 my $salt = "";
27 my @chars = ('.', '/', 0..9, 'A'..'Z', 'a'..'z');
28 $salt .= $chars[rand 64] for (1..8);
29 return crypt($password, '$6$' . $salt . '$');
30}
31
32
33# Functions for allocating free GIDs/UIDs. FIXME: respect ID ranges in
34# /etc/login.defs.
35sub allocId {
36 my ($used, $prevUsed, $idMin, $idMax, $up, $getid) = @_;
37 my $id = $up ? $idMin : $idMax;
38 while ($id >= $idMin && $id <= $idMax) {
39 if (!$used->{$id} && !$prevUsed->{$id} && !defined &$getid($id)) {
40 $used->{$id} = 1;
41 return $id;
42 }
43 $used->{$id} = 1;
44 if ($up) { $id++; } else { $id--; }
45 }
46 die "$0: out of free UIDs or GIDs\n";
47}
48
49my (%gidsUsed, %uidsUsed, %gidsPrevUsed, %uidsPrevUsed);
50
51sub allocGid {
52 my ($name) = @_;
53 my $prevGid = $gidMap->{$name};
54 if (defined $prevGid && !defined $gidsUsed{$prevGid}) {
55 print STDERR "reviving group '$name' with GID $prevGid\n";
56 $gidsUsed{$prevGid} = 1;
57 return $prevGid;
58 }
59 return allocId(\%gidsUsed, \%gidsPrevUsed, 400, 499, 0, sub { my ($gid) = @_; getgrgid($gid) });
60}
61
62sub allocUid {
63 my ($name, $isSystemUser) = @_;
64 my ($min, $max, $up) = $isSystemUser ? (400, 499, 0) : (1000, 29999, 1);
65 my $prevUid = $uidMap->{$name};
66 if (defined $prevUid && $prevUid >= $min && $prevUid <= $max && !defined $uidsUsed{$prevUid}) {
67 print STDERR "reviving user '$name' with UID $prevUid\n";
68 $uidsUsed{$prevUid} = 1;
69 return $prevUid;
70 }
71 return allocId(\%uidsUsed, \%uidsPrevUsed, $min, $max, $up, sub { my ($uid) = @_; getpwuid($uid) });
72}
73
74
75# Read the declared users/groups.
76my $spec = decode_json(read_file($ARGV[0]));
77
78# Don't allocate UIDs/GIDs that are manually assigned.
79foreach my $g (@{$spec->{groups}}) {
80 $gidsUsed{$g->{gid}} = 1 if defined $g->{gid};
81}
82
83foreach my $u (@{$spec->{users}}) {
84 $uidsUsed{$u->{uid}} = 1 if defined $u->{uid};
85}
86
87# Likewise for previously used but deleted UIDs/GIDs.
88$uidsPrevUsed{$_} = 1 foreach values %{$uidMap};
89$gidsPrevUsed{$_} = 1 foreach values %{$gidMap};
90
91
92# Read the current /etc/group.
93sub parseGroup {
94 chomp;
95 my @f = split(':', $_, -4);
96 my $gid = $f[2] eq "" ? undef : int($f[2]);
97 $gidsUsed{$gid} = 1 if defined $gid;
98 return ($f[0], { name => $f[0], password => $f[1], gid => $gid, members => $f[3] });
99}
100
101my %groupsCur = -f "/etc/group" ? map { parseGroup } read_file("/etc/group") : ();
102
103# Read the current /etc/passwd.
104sub parseUser {
105 chomp;
106 my @f = split(':', $_, -7);
107 my $uid = $f[2] eq "" ? undef : int($f[2]);
108 $uidsUsed{$uid} = 1 if defined $uid;
109 return ($f[0], { name => $f[0], fakePassword => $f[1], uid => $uid,
110 gid => $f[3], description => $f[4], home => $f[5], shell => $f[6] });
111}
112
113my %usersCur = -f "/etc/passwd" ? map { parseUser } read_file("/etc/passwd") : ();
114
115# Read the groups that were created declaratively (i.e. not by groups)
116# in the past. These must be removed if they are no longer in the
117# current spec.
118my $declGroupsFile = "/var/lib/nixos/declarative-groups";
119my %declGroups;
120$declGroups{$_} = 1 foreach split / /, -e $declGroupsFile ? read_file($declGroupsFile) : "";
121
122# Idem for the users.
123my $declUsersFile = "/var/lib/nixos/declarative-users";
124my %declUsers;
125$declUsers{$_} = 1 foreach split / /, -e $declUsersFile ? read_file($declUsersFile) : "";
126
127
128# Generate a new /etc/group containing the declared groups.
129my %groupsOut;
130foreach my $g (@{$spec->{groups}}) {
131 my $name = $g->{name};
132 my $existing = $groupsCur{$name};
133
134 my %members = map { ($_, 1) } @{$g->{members}};
135
136 if (defined $existing) {
137 $g->{gid} = $existing->{gid} if !defined $g->{gid};
138 if ($g->{gid} != $existing->{gid}) {
139 warn "warning: not applying GID change of group ‘$name’ ($existing->{gid} -> $g->{gid})\n";
140 $g->{gid} = $existing->{gid};
141 }
142 $g->{password} = $existing->{password}; # do we want this?
143 if ($spec->{mutableUsers}) {
144 # Merge in non-declarative group members.
145 foreach my $uname (split /,/, $existing->{members} // "") {
146 $members{$uname} = 1 if !defined $declUsers{$uname};
147 }
148 }
149 } else {
150 $g->{gid} = allocGid($name) if !defined $g->{gid};
151 $g->{password} = "x";
152 }
153
154 $g->{members} = join ",", sort(keys(%members));
155 $groupsOut{$name} = $g;
156
157 $gidMap->{$name} = $g->{gid};
158}
159
160# Update the persistent list of declarative groups.
161updateFile($declGroupsFile, join(" ", sort(keys %groupsOut)));
162
163# Merge in the existing /etc/group.
164foreach my $name (keys %groupsCur) {
165 my $g = $groupsCur{$name};
166 next if defined $groupsOut{$name};
167 if (!$spec->{mutableUsers} || defined $declGroups{$name}) {
168 print STDERR "removing group ‘$name’\n";
169 } else {
170 $groupsOut{$name} = $g;
171 }
172}
173
174
175# Rewrite /etc/group. FIXME: acquire lock.
176my @lines = map { join(":", $_->{name}, $_->{password}, $_->{gid}, $_->{members}) . "\n" }
177 (sort { $a->{gid} <=> $b->{gid} } values(%groupsOut));
178updateFile($gidMapFile, encode_json($gidMap));
179updateFile("/etc/group", \@lines);
180system("nscd --invalidate group");
181
182# Generate a new /etc/passwd containing the declared users.
183my %usersOut;
184foreach my $u (@{$spec->{users}}) {
185 my $name = $u->{name};
186
187 # Resolve the gid of the user.
188 if ($u->{group} =~ /^[0-9]$/) {
189 $u->{gid} = $u->{group};
190 } elsif (defined $groupsOut{$u->{group}}) {
191 $u->{gid} = $groupsOut{$u->{group}}->{gid} // die;
192 } else {
193 warn "warning: user ‘$name’ has unknown group ‘$u->{group}’\n";
194 $u->{gid} = 65534;
195 }
196
197 my $existing = $usersCur{$name};
198 if (defined $existing) {
199 $u->{uid} = $existing->{uid} if !defined $u->{uid};
200 if ($u->{uid} != $existing->{uid}) {
201 warn "warning: not applying UID change of user ‘$name’ ($existing->{uid} -> $u->{uid})\n";
202 $u->{uid} = $existing->{uid};
203 }
204 } else {
205 $u->{uid} = allocUid($name, $u->{isSystemUser}) if !defined $u->{uid};
206
207 if (defined $u->{initialPassword}) {
208 $u->{hashedPassword} = hashPassword($u->{initialPassword});
209 } elsif (defined $u->{initialHashedPassword}) {
210 $u->{hashedPassword} = $u->{initialHashedPassword};
211 }
212 }
213
214 # Create a home directory.
215 if ($u->{createHome}) {
216 make_path($u->{home}, { mode => 0700 }) if ! -e $u->{home};
217 chown $u->{uid}, $u->{gid}, $u->{home};
218 }
219
220 if (defined $u->{passwordFile}) {
221 if (-e $u->{passwordFile}) {
222 $u->{hashedPassword} = read_file($u->{passwordFile});
223 chomp $u->{hashedPassword};
224 } else {
225 warn "warning: password file ‘$u->{passwordFile}’ does not exist\n";
226 }
227 } elsif (defined $u->{password}) {
228 $u->{hashedPassword} = hashPassword($u->{password});
229 }
230
231 $u->{fakePassword} = $existing->{fakePassword} // "x";
232 $usersOut{$name} = $u;
233
234 $uidMap->{$name} = $u->{uid};
235}
236
237# Update the persistent list of declarative users.
238updateFile($declUsersFile, join(" ", sort(keys %usersOut)));
239
240# Merge in the existing /etc/passwd.
241foreach my $name (keys %usersCur) {
242 my $u = $usersCur{$name};
243 next if defined $usersOut{$name};
244 if (!$spec->{mutableUsers} || defined $declUsers{$name}) {
245 print STDERR "removing user ‘$name’\n";
246 } else {
247 $usersOut{$name} = $u;
248 }
249}
250
251# Rewrite /etc/passwd. FIXME: acquire lock.
252@lines = map { join(":", $_->{name}, $_->{fakePassword}, $_->{uid}, $_->{gid}, $_->{description}, $_->{home}, $_->{shell}) . "\n" }
253 (sort { $a->{uid} <=> $b->{uid} } (values %usersOut));
254updateFile($uidMapFile, encode_json($uidMap));
255updateFile("/etc/passwd", \@lines);
256system("nscd --invalidate passwd");
257
258
259# Rewrite /etc/shadow to add new accounts or remove dead ones.
260my @shadowNew;
261my %shadowSeen;
262
263foreach my $line (-f "/etc/shadow" ? read_file("/etc/shadow") : ()) {
264 chomp $line;
265 my ($name, $hashedPassword, @rest) = split(':', $line, -9);
266 my $u = $usersOut{$name};;
267 next if !defined $u;
268 $hashedPassword = "!" if !$spec->{mutableUsers};
269 $hashedPassword = $u->{hashedPassword} if defined $u->{hashedPassword} && !$spec->{mutableUsers}; # FIXME
270 push @shadowNew, join(":", $name, $hashedPassword, @rest) . "\n";
271 $shadowSeen{$name} = 1;
272}
273
274foreach my $u (values %usersOut) {
275 next if defined $shadowSeen{$u->{name}};
276 my $hashedPassword = "!";
277 $hashedPassword = $u->{hashedPassword} if defined $u->{hashedPassword};
278 # FIXME: set correct value for sp_lstchg.
279 push @shadowNew, join(":", $u->{name}, $hashedPassword, "1::::::") . "\n";
280}
281
282updateFile("/etc/shadow", \@shadowNew, 0600);