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