]>
Commit | Line | Data |
---|---|---|
7af97ad5 | 1 | package PVE::LXC::Setup::Base; |
1c7f4f65 DM |
2 | |
3 | use strict; | |
4 | use warnings; | |
5 | ||
168d6b07 DM |
6 | use File::stat; |
7 | use Digest::SHA; | |
8 | use IO::File; | |
9 | use Encode; | |
2063d380 | 10 | use File::Path; |
168d6b07 | 11 | |
b9cd9975 | 12 | use PVE::INotify; |
55fa4e09 DM |
13 | use PVE::Tools; |
14 | ||
633a7bd8 | 15 | sub new { |
5b4657d0 | 16 | my ($class, $conf, $rootdir) = @_; |
633a7bd8 | 17 | |
5b4657d0 | 18 | return bless { conf => $conf, rootdir => $rootdir }, $class; |
633a7bd8 | 19 | } |
b9cd9975 | 20 | |
c0eae401 | 21 | sub lookup_dns_conf { |
b9cd9975 DM |
22 | my ($conf) = @_; |
23 | ||
27916659 DM |
24 | my $nameserver = $conf->{nameserver}; |
25 | my $searchdomains = $conf->{searchdomain}; | |
b9cd9975 DM |
26 | |
27 | if (!($nameserver && $searchdomains)) { | |
28 | ||
27916659 | 29 | if ($conf->{'testmode'}) { |
b9cd9975 DM |
30 | |
31 | $nameserver = "8.8.8.8 8.8.8.9"; | |
32 | $searchdomains = "promxox.com"; | |
33 | ||
34 | } else { | |
35 | ||
36 | my $host_resolv_conf = PVE::INotify::read_file('resolvconf'); | |
37 | ||
38 | $searchdomains = $host_resolv_conf->{search}; | |
39 | ||
40 | my @list = (); | |
41 | foreach my $k ("dns1", "dns2", "dns3") { | |
42 | if (my $ns = $host_resolv_conf->{$k}) { | |
43 | push @list, $ns; | |
44 | } | |
45 | } | |
46 | $nameserver = join(' ', @list); | |
47 | } | |
48 | } | |
49 | ||
50 | return ($searchdomains, $nameserver); | |
c0eae401 | 51 | } |
b9cd9975 | 52 | |
c0eae401 | 53 | sub update_etc_hosts { |
e4929e97 | 54 | my ($etc_hosts_data, $hostip, $oldname, $newname, $searchdomains) = @_; |
1c7f4f65 | 55 | |
1c7f4f65 DM |
56 | my $done = 0; |
57 | ||
58 | my @lines; | |
e4929e97 DM |
59 | |
60 | my $extra_names = ''; | |
61 | foreach my $domain (PVE::Tools::split_list($searchdomains)) { | |
62 | $extra_names .= ' ' if $extra_names; | |
63 | $extra_names .= "$newname.$domain"; | |
64 | } | |
1c7f4f65 DM |
65 | |
66 | foreach my $line (split(/\n/, $etc_hosts_data)) { | |
67 | if ($line =~ m/^#/ || $line =~ m/^\s*$/) { | |
68 | push @lines, $line; | |
69 | next; | |
70 | } | |
71 | ||
72 | my ($ip, @names) = split(/\s+/, $line); | |
73 | if (($ip eq '127.0.0.1') || ($ip eq '::1')) { | |
74 | push @lines, $line; | |
75 | next; | |
76 | } | |
c325b32f | 77 | |
1c7f4f65 DM |
78 | my $found = 0; |
79 | foreach my $name (@names) { | |
80 | if ($name eq $oldname || $name eq $newname) { | |
81 | $found = 1; | |
82 | } else { | |
83 | # fixme: record extra names? | |
84 | } | |
85 | } | |
86 | $found = 1 if defined($hostip) && ($ip eq $hostip); | |
87 | ||
88 | if ($found) { | |
89 | if (!$done) { | |
90 | if (defined($hostip)) { | |
c325b32f | 91 | push @lines, "$hostip $extra_names $newname"; |
1c7f4f65 DM |
92 | } else { |
93 | push @lines, "127.0.1.1 $newname"; | |
94 | } | |
95 | $done = 1; | |
96 | } | |
97 | next; | |
98 | } else { | |
99 | push @lines, $line; | |
100 | } | |
101 | } | |
102 | ||
103 | if (!$done) { | |
104 | if (defined($hostip)) { | |
e4929e97 | 105 | push @lines, "$hostip $extra_names $newname"; |
1c7f4f65 DM |
106 | } else { |
107 | push @lines, "127.0.1.1 $newname"; | |
108 | } | |
109 | } | |
110 | ||
1e180f97 DM |
111 | my $found_localhost = 0; |
112 | foreach my $line (@lines) { | |
113 | if ($line =~ m/^127.0.0.1\s/) { | |
114 | $found_localhost = 1; | |
115 | last; | |
116 | } | |
117 | } | |
118 | ||
119 | if (!$found_localhost) { | |
120 | unshift @lines, "127.0.0.1 localhost.localnet localhost"; | |
121 | } | |
122 | ||
1c7f4f65 DM |
123 | $etc_hosts_data = join("\n", @lines) . "\n"; |
124 | ||
125 | return $etc_hosts_data; | |
c0eae401 | 126 | } |
1c7f4f65 | 127 | |
142444d5 DM |
128 | sub template_fixup { |
129 | my ($self, $conf) = @_; | |
130 | ||
131 | # do nothing by default | |
132 | } | |
133 | ||
c325b32f | 134 | sub set_dns { |
633a7bd8 | 135 | my ($self, $conf) = @_; |
c325b32f | 136 | |
c0eae401 | 137 | my ($searchdomains, $nameserver) = lookup_dns_conf($conf); |
c325b32f | 138 | |
5b4657d0 | 139 | my $rootdir = $self->{rootdir}; |
c325b32f | 140 | |
5b4657d0 | 141 | my $filename = "$rootdir/etc/resolv.conf"; |
c325b32f DM |
142 | |
143 | my $data = ''; | |
144 | ||
145 | $data .= "search " . join(' ', PVE::Tools::split_list($searchdomains)) . "\n" | |
146 | if $searchdomains; | |
147 | ||
148 | foreach my $ns ( PVE::Tools::split_list($nameserver)) { | |
149 | $data .= "nameserver $ns\n"; | |
150 | } | |
151 | ||
152 | PVE::Tools::file_set_contents($filename, $data); | |
153 | } | |
154 | ||
1c7f4f65 | 155 | sub set_hostname { |
633a7bd8 | 156 | my ($self, $conf) = @_; |
1c7f4f65 | 157 | |
27916659 | 158 | my $hostname = $conf->{hostname} || 'localhost'; |
1c7f4f65 DM |
159 | |
160 | $hostname =~ s/\..*$//; | |
161 | ||
5b4657d0 | 162 | my $rootdir = $self->{rootdir}; |
1c7f4f65 | 163 | |
5b4657d0 | 164 | my $hostname_fn = "$rootdir/etc/hostname"; |
1c7f4f65 | 165 | |
e4929e97 | 166 | my $oldname = PVE::Tools::file_read_firstline($hostname_fn) || 'localhost'; |
1c7f4f65 | 167 | |
5b4657d0 | 168 | my $hosts_fn = "$rootdir/etc/hosts"; |
1c7f4f65 DM |
169 | my $etc_hosts_data = ''; |
170 | ||
171 | if (-f $hosts_fn) { | |
172 | $etc_hosts_data = PVE::Tools::file_get_contents($hosts_fn); | |
173 | } | |
174 | ||
c325b32f DM |
175 | my ($ipv4, $ipv6) = PVE::LXC::get_primary_ips($conf); |
176 | my $hostip = $ipv4 || $ipv6; | |
b9cd9975 | 177 | |
c0eae401 | 178 | my ($searchdomains) = lookup_dns_conf($conf); |
b9cd9975 | 179 | |
c0eae401 DM |
180 | $etc_hosts_data = update_etc_hosts($etc_hosts_data, $hostip, $oldname, |
181 | $hostname, $searchdomains); | |
b9cd9975 | 182 | |
1c7f4f65 DM |
183 | PVE::Tools::file_set_contents($hostname_fn, "$hostname\n"); |
184 | PVE::Tools::file_set_contents($hosts_fn, $etc_hosts_data); | |
185 | } | |
186 | ||
55fa4e09 | 187 | sub setup_network { |
633a7bd8 | 188 | my ($self, $conf) = @_; |
55fa4e09 DM |
189 | |
190 | die "please implement this inside subclass" | |
191 | } | |
192 | ||
d66768a2 | 193 | sub setup_init { |
633a7bd8 | 194 | my ($self, $conf) = @_; |
1c7f4f65 | 195 | |
d66768a2 DM |
196 | die "please implement this inside subclass" |
197 | } | |
198 | ||
9143dec4 DM |
199 | sub setup_systemd_console { |
200 | my ($self, $conf) = @_; | |
201 | ||
202 | my $rootdir = $self->{rootdir}; | |
203 | ||
204 | my $systemd_dir_rel = -x "$rootdir/lib/systemd/systemd" ? | |
205 | "/lib/systemd/system" : "/usr/lib/systemd/system"; | |
206 | ||
207 | my $systemd_dir = "$rootdir/$systemd_dir_rel"; | |
208 | ||
209 | my $etc_systemd_dir = "$rootdir/etc/systemd/system"; | |
210 | ||
211 | my $systemd_getty_service_rel = "$systemd_dir_rel/getty\@.service"; | |
212 | ||
213 | my $systemd_getty_service = "$rootdir/$systemd_getty_service_rel"; | |
214 | ||
215 | return if ! -f $systemd_getty_service; | |
216 | ||
217 | my $raw = PVE::Tools::file_get_contents($systemd_getty_service); | |
218 | ||
c69ae0d0 DM |
219 | my $systemd_container_getty_service_rel = "$systemd_dir_rel/container-getty\@.service"; |
220 | my $systemd_container_getty_service = "$rootdir/$systemd_container_getty_service_rel"; | |
221 | ||
222 | # systemd on CenoOS 7.1 is too old (version 205), so there is no | |
223 | # container-getty service | |
224 | if (! -f $systemd_container_getty_service) { | |
225 | if ($raw =~ s!^ConditionPathExists=/dev/tty0$!ConditionPathExists=/dev/tty!m) { | |
226 | PVE::Tools::file_set_contents($systemd_getty_service, $raw); | |
227 | } | |
228 | } else { | |
229 | # undo above change (in case someone updated systemd) | |
230 | if ($raw =~ s!^ConditionPathExists=/dev/tty$!ConditionPathExists=/dev/tty0!m) { | |
231 | PVE::Tools::file_set_contents($systemd_getty_service, $raw); | |
232 | } | |
9143dec4 DM |
233 | } |
234 | ||
0d0ca400 | 235 | my $ttycount = PVE::LXC::get_tty_count($conf); |
9143dec4 DM |
236 | |
237 | for (my $i = 1; $i < 7; $i++) { | |
238 | my $tty_service_lnk = "$etc_systemd_dir/getty.target.wants/getty\@tty$i.service"; | |
239 | if ($i > $ttycount) { | |
240 | unlink $tty_service_lnk; | |
241 | } else { | |
242 | if (! -l $tty_service_lnk) { | |
243 | unlink $tty_service_lnk; | |
244 | symlink($systemd_getty_service_rel, $tty_service_lnk); | |
245 | } | |
246 | } | |
247 | } | |
248 | } | |
249 | ||
c1d32b55 WB |
250 | sub setup_systemd_networkd { |
251 | my ($self, $conf) = @_; | |
252 | ||
253 | my $rootdir = $self->{rootdir}; | |
254 | ||
255 | foreach my $k (keys %$conf) { | |
256 | next if $k !~ m/^net(\d+)$/; | |
257 | my $d = PVE::LXC::parse_lxc_network($conf->{$k}); | |
258 | next if !$d->{name}; | |
259 | ||
260 | my $filename = "$rootdir/etc/systemd/network/$d->{name}.network"; | |
261 | ||
262 | my $data = <<"DATA"; | |
263 | [Match] | |
264 | Name = $d->{name} | |
265 | ||
266 | [Network] | |
267 | Description = Interface $d->{name} autoconfigured by PVE | |
268 | DATA | |
269 | # DHCP bitflags: | |
270 | my @DHCPMODES = ('none', 'v4', 'v6', 'both'); | |
271 | my ($NONE, $DHCP4, $DHCP6, $BOTH) = (0, 1, 2, 3); | |
272 | my $dhcp = $NONE; | |
273 | ||
274 | if (defined(my $ip = $d->{ip})) { | |
275 | if ($ip eq 'dhcp') { | |
276 | $dhcp |= $DHCP4; | |
277 | } elsif ($ip ne 'manual') { | |
278 | $data .= "Address = $ip\n"; | |
279 | } | |
280 | } | |
281 | if (defined(my $gw = $d->{gw})) { | |
282 | $data .= "Gateway = $gw\n"; | |
283 | } | |
284 | ||
285 | if (defined(my $ip = $d->{ip6})) { | |
286 | if ($ip eq 'dhcp') { | |
287 | $dhcp |= $DHCP6; | |
288 | } elsif ($ip ne 'manual') { | |
289 | $data .= "Address = $ip\n"; | |
290 | } | |
291 | } | |
292 | if (defined(my $gw = $d->{gw6})) { | |
293 | $data .= "Gateway = $gw\n"; | |
294 | } | |
295 | ||
296 | $data .= "DHCP = $DHCPMODES[$dhcp]\n"; | |
297 | ||
298 | PVE::Tools::file_set_contents($filename, $data); | |
299 | } | |
b7cd927f WB |
300 | } |
301 | ||
302 | sub setup_securetty { | |
303 | my ($self, $conf, @add) = @_; | |
c1d32b55 | 304 | |
b7cd927f WB |
305 | my $rootdir = $self->{rootdir}; |
306 | my $filename = "$rootdir/etc/securetty"; | |
307 | my $data = PVE::Tools::file_get_contents($filename); | |
308 | chomp $data; $data .= "\n"; | |
309 | foreach my $dev (@add) { | |
310 | if ($data !~ m!^\Q$dev\E\s*$!m) { | |
311 | $data .= "$dev\n"; | |
312 | } | |
313 | } | |
314 | PVE::Tools::file_set_contents($filename, $data); | |
c1d32b55 WB |
315 | } |
316 | ||
168d6b07 | 317 | my $replacepw = sub { |
367a7c18 | 318 | my ($file, $user, $epw, $shadow) = @_; |
168d6b07 DM |
319 | |
320 | my $tmpfile = "$file.$$"; | |
321 | ||
322 | eval { | |
323 | my $src = IO::File->new("<$file") || | |
324 | die "unable to open file '$file' - $!"; | |
325 | ||
326 | my $st = File::stat::stat($src) || | |
327 | die "unable to stat file - $!"; | |
328 | ||
329 | my $dst = IO::File->new(">$tmpfile") || | |
330 | die "unable to open file '$tmpfile' - $!"; | |
331 | ||
332 | # copy owner and permissions | |
333 | chmod $st->mode, $dst; | |
334 | chown $st->uid, $st->gid, $dst; | |
367a7c18 DM |
335 | |
336 | my $last_change = int(time()/(60*60*24)); | |
337 | ||
338 | if ($epw =~ m/^\$TEST\$/) { # for regression tests | |
339 | $last_change = 12345; | |
340 | } | |
168d6b07 DM |
341 | |
342 | while (defined (my $line = <$src>)) { | |
367a7c18 DM |
343 | if ($shadow) { |
344 | $line =~ s/^${user}:[^:]*:[^:]*:/${user}:${epw}:${last_change}:/; | |
345 | } else { | |
346 | $line =~ s/^${user}:[^:]*:/${user}:${epw}:/; | |
347 | } | |
168d6b07 DM |
348 | print $dst $line; |
349 | } | |
350 | ||
351 | $src->close() || die "close '$file' failed - $!\n"; | |
352 | $dst->close() || die "close '$tmpfile' failed - $!\n"; | |
353 | }; | |
354 | if (my $err = $@) { | |
355 | unlink $tmpfile; | |
356 | } else { | |
357 | rename $tmpfile, $file; | |
358 | unlink $tmpfile; # in case rename fails | |
359 | } | |
360 | }; | |
361 | ||
362 | sub set_user_password { | |
633a7bd8 | 363 | my ($self, $conf, $user, $opt_password) = @_; |
168d6b07 | 364 | |
5b4657d0 | 365 | my $rootdir = $self->{rootdir}; |
168d6b07 | 366 | |
5b4657d0 | 367 | my $pwfile = "$rootdir/etc/passwd"; |
168d6b07 DM |
368 | |
369 | return if ! -f $pwfile; | |
370 | ||
5b4657d0 | 371 | my $shadow = "$rootdir/etc/shadow"; |
168d6b07 DM |
372 | |
373 | if (defined($opt_password)) { | |
374 | if ($opt_password !~ m/^\$/) { | |
375 | my $time = substr (Digest::SHA::sha1_base64 (time), 0, 8); | |
376 | $opt_password = crypt(encode("utf8", $opt_password), "\$1\$$time\$"); | |
377 | }; | |
378 | } else { | |
379 | $opt_password = '*'; | |
380 | } | |
381 | ||
382 | if (-f $shadow) { | |
367a7c18 | 383 | &$replacepw ($shadow, $user, $opt_password, 1); |
168d6b07 DM |
384 | &$replacepw ($pwfile, $user, 'x'); |
385 | } else { | |
386 | &$replacepw ($pwfile, $user, $opt_password); | |
387 | } | |
388 | } | |
389 | ||
4727bd09 DM |
390 | my $randomize_crontab = sub { |
391 | my ($self, $conf) = @_; | |
392 | ||
393 | my $rootdir = $self->{rootdir}; | |
394 | ||
b5e62cd0 DM |
395 | my @files; |
396 | # Note: dir_glob_foreach() untaints filenames! | |
397 | my $cron_dir = "$rootdir/etc/cron.d"; | |
398 | PVE::Tools::dir_glob_foreach($cron_dir, qr/[A-Z\-\_a-z0-9]+/, sub { | |
399 | my ($name) = @_; | |
400 | push @files, "$cron_dir/$name"; | |
401 | }); | |
4727bd09 DM |
402 | |
403 | my $crontab_fn = "$rootdir/etc/crontab"; | |
404 | unshift @files, $crontab_fn if -f $crontab_fn; | |
405 | ||
406 | foreach my $filename (@files) { | |
407 | my $data = PVE::Tools::file_get_contents($filename); | |
408 | my $new = ''; | |
409 | foreach my $line (split(/\n/, $data)) { | |
410 | # we only randomize minutes for root crontab entries | |
411 | if ($line =~ m/^\d+(\s+\S+\s+\S+\s+\S+\s+\S+\s+root\s+\S.*)$/) { | |
412 | my $rest = $1; | |
413 | my $min = int(rand()*59); | |
414 | $new .= "$min$rest\n"; | |
415 | } else { | |
416 | $new .= "$line\n"; | |
417 | } | |
418 | } | |
419 | PVE::Tools::file_set_contents($filename, $new); | |
420 | } | |
421 | }; | |
422 | ||
7ee31468 DM |
423 | sub rewrite_ssh_host_keys { |
424 | my ($self, $conf) = @_; | |
425 | ||
426 | my $rootdir = $self->{rootdir}; | |
427 | ||
428 | my $etc_ssh_dir = "$rootdir/etc/ssh"; | |
429 | ||
430 | return if ! -d $etc_ssh_dir; | |
431 | ||
432 | my $keynames = { | |
433 | rsa1 => 'ssh_host_key', | |
434 | rsa => 'ssh_host_rsa_key', | |
435 | dsa => 'ssh_host_dsa_key', | |
436 | ecdsa => 'ssh_host_ecdsa_key', | |
437 | ed25519 => 'ssh_host_ed25519_key', | |
438 | }; | |
439 | ||
27916659 | 440 | my $hostname = $conf->{hostname} || 'localhost'; |
7ee31468 DM |
441 | $hostname =~ s/\..*$//; |
442 | ||
443 | foreach my $keytype (keys %$keynames) { | |
444 | my $basename = $keynames->{$keytype}; | |
445 | unlink "${etc_ssh_dir}/$basename"; | |
446 | unlink "${etc_ssh_dir}/$basename.pub"; | |
447 | print "Creating SSH host key '$basename' - this may take some time ...\n"; | |
448 | my $cmd = ['ssh-keygen', '-q', '-f', "${etc_ssh_dir}/$basename", '-t', $keytype, | |
449 | '-N', '', '-C', "root\@$hostname"]; | |
450 | PVE::Tools::run_command($cmd); | |
451 | } | |
452 | } | |
453 | ||
d66768a2 | 454 | sub pre_start_hook { |
633a7bd8 | 455 | my ($self, $conf) = @_; |
d66768a2 | 456 | |
633a7bd8 DM |
457 | $self->setup_init($conf); |
458 | $self->setup_network($conf); | |
459 | $self->set_hostname($conf); | |
460 | $self->set_dns($conf); | |
d66768a2 DM |
461 | |
462 | # fixme: what else ? | |
463 | } | |
464 | ||
465 | sub post_create_hook { | |
633a7bd8 | 466 | my ($self, $conf, $root_password) = @_; |
d66768a2 | 467 | |
142444d5 | 468 | $self->template_fixup($conf); |
4727bd09 DM |
469 | |
470 | &$randomize_crontab($self, $conf); | |
471 | ||
633a7bd8 DM |
472 | $self->set_user_password($conf, 'root', $root_password); |
473 | $self->setup_init($conf); | |
474 | $self->setup_network($conf); | |
475 | $self->set_hostname($conf); | |
476 | $self->set_dns($conf); | |
7ee31468 | 477 | $self->rewrite_ssh_host_keys($conf); |
168d6b07 | 478 | |
55fa4e09 | 479 | # fixme: what else ? |
1c7f4f65 DM |
480 | } |
481 | ||
2063d380 WB |
482 | sub ct_mkdir { |
483 | my ($self, $file, $mask) = @_; | |
484 | my $root = $self->{rootdir}; | |
485 | $file //= $_; # emulate mkdir parameters | |
486 | return CORE::mkdir("$root/$file", $mask) if defined ($mask); | |
487 | return CORE::mkdir("$root/$file"); | |
488 | } | |
489 | ||
490 | sub ct_unlink { | |
491 | my $self = shift; | |
492 | my $root = $self->{rootdir}; | |
493 | return CORE::unlink("$root/$_") if !@_; # emulate unlink parameters | |
494 | return CORE::unlink(map { "$root/$_" } @_); | |
495 | } | |
496 | ||
497 | sub ct_open_file { | |
498 | my $self = shift; | |
499 | my $file = $self->{rootdir} . '/' . shift; | |
500 | return IO::File->new($file, @_); | |
501 | } | |
502 | ||
503 | sub ct_make_path { | |
504 | my $self = shift; | |
505 | my $root = $self->{rootdir}; | |
506 | my $opt = pop; | |
507 | $opt = "$root/$opt" if ref($opt) ne 'HASH'; | |
508 | return File::Path::make_path(map { "$root/$_" } @_, $opt); | |
509 | } | |
510 | ||
511 | sub ct_mkpath { | |
512 | my $self = shift; | |
513 | my $root = $self->{rootdir}; | |
514 | ||
515 | my $first = shift; | |
516 | return File::Path::mkpath(map { "$root/$_" } @$first, @_) if ref($first) eq 'ARRAY'; | |
517 | unshift @_, $first; | |
518 | my $last = pop; | |
519 | return File::Path::mkpath(map { "$root/$_" } @_, $last) if ref($last) eq 'HASH'; | |
520 | return File::Path::mkpath(map { "$root/$_" } (@_, $last||())); | |
521 | } | |
522 | ||
523 | sub ct_symlink { | |
524 | my ($self, $old, $new) = @_; | |
525 | my $root = $self->{rootdir}; | |
526 | return CORE::symlink($old, "$root/$new"); | |
527 | } | |
528 | ||
529 | sub ct_file_exists { | |
530 | my ($self, $file) = @_; | |
531 | my $root = $self->{rootdir}; | |
532 | return -f "$root/$file"; | |
533 | } | |
534 | ||
535 | sub ct_file_read_firstline { | |
536 | my ($self, $file) = @_; | |
537 | my $root = $self->{rootdir}; | |
538 | return PVE::Tools::file_read_firstline("$root/$file"); | |
539 | } | |
540 | ||
541 | sub ct_file_get_contents { | |
542 | my ($self, $file) = @_; | |
543 | my $root = $self->{rootdir}; | |
544 | return PVE::Tools::file_get_contents("$root/$file"); | |
545 | } | |
546 | ||
547 | sub ct_file_set_contents { | |
548 | my ($self, $file, $data) = @_; | |
549 | my $root = $self->{rootdir}; | |
550 | return PVE::Tools::file_set_contents("$root/$file", $data); | |
551 | } | |
552 | ||
1c7f4f65 | 553 | 1; |