Merge pull request #65231 from buckley310/grub-password

grub: add support for passwords
This commit is contained in:
Michele Guerini Rocco 2020-07-01 09:04:30 +02:00 committed by GitHub
commit dab676b2d7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 200 additions and 3 deletions

View File

@ -360,6 +360,18 @@ start_all()
</note> </note>
</listitem> </listitem>
</varlistentry> </varlistentry>
<varlistentry>
<term>
<methodname>wait_for_console_text</methodname>
</term>
<listitem>
<para>
Wait until the supplied regular expressions match a line of the serial
console output. This method is useful when OCR is not possibile or
accurate enough.
</para>
</listitem>
</varlistentry>
<varlistentry> <varlistentry>
<term> <term>
<methodname>wait_for_window</methodname> <methodname>wait_for_window</methodname>

View File

@ -3,6 +3,8 @@ from contextlib import contextmanager, _GeneratorContextManager
from queue import Queue, Empty from queue import Queue, Empty
from typing import Tuple, Any, Callable, Dict, Iterator, Optional, List from typing import Tuple, Any, Callable, Dict, Iterator, Optional, List
from xml.sax.saxutils import XMLGenerator from xml.sax.saxutils import XMLGenerator
import queue
import io
import _thread import _thread
import argparse import argparse
import atexit import atexit
@ -672,6 +674,22 @@ class Machine:
with self.nested("waiting for {} to appear on screen".format(regex)): with self.nested("waiting for {} to appear on screen".format(regex)):
retry(screen_matches) retry(screen_matches)
def wait_for_console_text(self, regex: str) -> None:
self.log("waiting for {} to appear on console".format(regex))
# Buffer the console output, this is needed
# to match multiline regexes.
console = io.StringIO()
while True:
try:
console.write(self.last_lines.get())
except queue.Empty:
self.sleep(1)
continue
console.seek(0)
matches = re.search(regex, console.read())
if matches is not None:
return
def send_key(self, key: str) -> None: def send_key(self, key: str) -> None:
key = CHAR_TO_KEY.get(key, key) key = CHAR_TO_KEY.get(key, key)
self.send_monitor_command("sendkey {}".format(key)) self.send_monitor_command("sendkey {}".format(key))
@ -735,11 +753,16 @@ class Machine:
self.monitor, _ = self.monitor_socket.accept() self.monitor, _ = self.monitor_socket.accept()
self.shell, _ = self.shell_socket.accept() self.shell, _ = self.shell_socket.accept()
# Store last serial console lines for use
# of wait_for_console_text
self.last_lines: Queue = Queue()
def process_serial_output() -> None: def process_serial_output() -> None:
assert self.process.stdout is not None assert self.process.stdout is not None
for _line in self.process.stdout: for _line in self.process.stdout:
# Ignore undecodable bytes that may occur in boot menus # Ignore undecodable bytes that may occur in boot menus
line = _line.decode(errors="ignore").replace("\r", "").rstrip() line = _line.decode(errors="ignore").replace("\r", "").rstrip()
self.last_lines.put(line)
eprint("{} # {}".format(self.name, line)) eprint("{} # {}".format(self.name, line))
self.logger.enqueue({"msg": line, "machine": self.name}) self.logger.enqueue({"msg": line, "machine": self.name})

View File

@ -55,6 +55,7 @@ let
storePath = config.boot.loader.grub.storePath; storePath = config.boot.loader.grub.storePath;
bootloaderId = if args.efiBootloaderId == null then "NixOS${efiSysMountPoint'}" else args.efiBootloaderId; bootloaderId = if args.efiBootloaderId == null then "NixOS${efiSysMountPoint'}" else args.efiBootloaderId;
timeout = if config.boot.loader.timeout == null then -1 else config.boot.loader.timeout; timeout = if config.boot.loader.timeout == null then -1 else config.boot.loader.timeout;
users = if cfg.users == {} || cfg.version != 1 then cfg.users else throw "GRUB version 1 does not support user accounts.";
inherit efiSysMountPoint; inherit efiSysMountPoint;
inherit (args) devices; inherit (args) devices;
inherit (efi) canTouchEfiVariables; inherit (efi) canTouchEfiVariables;
@ -137,6 +138,67 @@ in
''; '';
}; };
users = mkOption {
default = {};
example = {
root = { hashedPasswordFile = "/path/to/file"; };
};
description = ''
User accounts for GRUB. When specified, the GRUB command line and
all boot options except the default are password-protected.
All passwords and hashes provided will be stored in /boot/grub/grub.cfg,
and will be visible to any local user who can read this file. Additionally,
any passwords and hashes provided directly in a Nix configuration
(as opposed to external files) will be copied into the Nix store, and
will be visible to all local users.
'';
type = with types; attrsOf (submodule {
options = {
hashedPasswordFile = mkOption {
example = "/path/to/file";
default = null;
type = with types; uniq (nullOr str);
description = ''
Specifies the path to a file containing the password hash
for the account, generated with grub-mkpasswd-pbkdf2.
This hash will be stored in /boot/grub/grub.cfg, and will
be visible to any local user who can read this file.
'';
};
hashedPassword = mkOption {
example = "grub.pbkdf2.sha512.10000.674DFFDEF76E13EA...2CC972B102CF4355";
default = null;
type = with types; uniq (nullOr str);
description = ''
Specifies the password hash for the account,
generated with grub-mkpasswd-pbkdf2.
This hash will be copied to the Nix store, and will be visible to all local users.
'';
};
passwordFile = mkOption {
example = "/path/to/file";
default = null;
type = with types; uniq (nullOr str);
description = ''
Specifies the path to a file containing the
clear text password for the account.
This password will be stored in /boot/grub/grub.cfg, and will
be visible to any local user who can read this file.
'';
};
password = mkOption {
example = "Pa$$w0rd!";
default = null;
type = with types; uniq (nullOr str);
description = ''
Specifies the clear text password for the account.
This password will be copied to the Nix store, and will be visible to all local users.
'';
};
};
});
};
mirroredBoots = mkOption { mirroredBoots = mkOption {
default = [ ]; default = [ ];
example = [ example = [

View File

@ -247,6 +247,45 @@ if ($grubVersion == 1) {
} }
else { else {
my @users = ();
foreach my $user ($dom->findnodes('/expr/attrs/attr[@name = "users"]/attrs/attr')) {
my $name = $user->findvalue('@name') or die;
my $hashedPassword = $user->findvalue('./attrs/attr[@name = "hashedPassword"]/string/@value');
my $hashedPasswordFile = $user->findvalue('./attrs/attr[@name = "hashedPasswordFile"]/string/@value');
my $password = $user->findvalue('./attrs/attr[@name = "password"]/string/@value');
my $passwordFile = $user->findvalue('./attrs/attr[@name = "passwordFile"]/string/@value');
if ($hashedPasswordFile) {
open(my $f, '<', $hashedPasswordFile) or die "Can't read file '$hashedPasswordFile'!";
$hashedPassword = <$f>;
chomp $hashedPassword;
}
if ($passwordFile) {
open(my $f, '<', $passwordFile) or die "Can't read file '$passwordFile'!";
$password = <$f>;
chomp $password;
}
if ($hashedPassword) {
if (index($hashedPassword, "grub.pbkdf2.") == 0) {
$conf .= "\npassword_pbkdf2 $name $hashedPassword";
}
else {
die "Password hash for GRUB user '$name' is not valid!";
}
}
elsif ($password) {
$conf .= "\npassword $name $password";
}
else {
die "GRUB user '$name' has no password!";
}
push(@users, $name);
}
if (@users) {
$conf .= "\nset superusers=\"" . join(' ',@users) . "\"\n";
}
if ($copyKernels == 0) { if ($copyKernels == 0) {
$conf .= " $conf .= "
" . $grubStore->search; " . $grubStore->search;
@ -350,7 +389,7 @@ sub copyToKernelsDir {
} }
sub addEntry { sub addEntry {
my ($name, $path) = @_; my ($name, $path, $options) = @_;
return unless -e "$path/kernel" && -e "$path/initrd"; return unless -e "$path/kernel" && -e "$path/initrd";
my $kernel = copyToKernelsDir(Cwd::abs_path("$path/kernel")); my $kernel = copyToKernelsDir(Cwd::abs_path("$path/kernel"));
@ -396,7 +435,7 @@ sub addEntry {
$conf .= " " . ($xen ? "module" : "kernel") . " $kernel $kernelParams\n"; $conf .= " " . ($xen ? "module" : "kernel") . " $kernel $kernelParams\n";
$conf .= " " . ($xen ? "module" : "initrd") . " $initrd\n\n"; $conf .= " " . ($xen ? "module" : "initrd") . " $initrd\n\n";
} else { } else {
$conf .= "menuentry \"$name\" {\n"; $conf .= "menuentry \"$name\" " . ($options||"") . " {\n";
$conf .= $grubBoot->search . "\n"; $conf .= $grubBoot->search . "\n";
if ($copyKernels == 0) { if ($copyKernels == 0) {
$conf .= $grubStore->search . "\n"; $conf .= $grubStore->search . "\n";
@ -413,7 +452,7 @@ sub addEntry {
# Add default entries. # Add default entries.
$conf .= "$extraEntries\n" if $extraEntriesBeforeNixOS; $conf .= "$extraEntries\n" if $extraEntriesBeforeNixOS;
addEntry("NixOS - Default", $defaultConfig); addEntry("NixOS - Default", $defaultConfig, "--unrestricted");
$conf .= "$extraEntries\n" unless $extraEntriesBeforeNixOS; $conf .= "$extraEntries\n" unless $extraEntriesBeforeNixOS;

View File

@ -126,6 +126,7 @@ in
grafana = handleTest ./grafana.nix {}; grafana = handleTest ./grafana.nix {};
graphite = handleTest ./graphite.nix {}; graphite = handleTest ./graphite.nix {};
graylog = handleTest ./graylog.nix {}; graylog = handleTest ./graylog.nix {};
grub = handleTest ./grub.nix {};
gvisor = handleTest ./gvisor.nix {}; gvisor = handleTest ./gvisor.nix {};
hadoop.hdfs = handleTestOn [ "x86_64-linux" ] ./hadoop/hdfs.nix {}; hadoop.hdfs = handleTestOn [ "x86_64-linux" ] ./hadoop/hdfs.nix {};
hadoop.yarn = handleTestOn [ "x86_64-linux" ] ./hadoop/yarn.nix {}; hadoop.yarn = handleTestOn [ "x86_64-linux" ] ./hadoop/yarn.nix {};

60
nixos/tests/grub.nix Normal file
View File

@ -0,0 +1,60 @@
import ./make-test-python.nix ({ lib, ... }: {
name = "grub";
meta = with lib.maintainers; {
maintainers = [ rnhmjoj ];
};
machine = { ... }: {
virtualisation.useBootLoader = true;
boot.loader.timeout = null;
boot.loader.grub = {
enable = true;
users.alice.password = "supersecret";
# OCR is not accurate enough
extraConfig = "serial; terminal_output serial";
};
};
testScript = ''
def grub_login_as(user, password):
"""
Enters user and password to log into GRUB
"""
machine.wait_for_console_text("Enter username:")
machine.send_chars(user + "\n")
machine.wait_for_console_text("Enter password:")
machine.send_chars(password + "\n")
def grub_select_all_configurations():
"""
Selects "All configurations" from the GRUB menu
to trigger a login request.
"""
machine.send_monitor_command("sendkey down")
machine.send_monitor_command("sendkey ret")
machine.start()
# wait for grub screen
machine.wait_for_console_text("GNU GRUB")
grub_select_all_configurations()
with subtest("Invalid credentials are rejected"):
grub_login_as("wronguser", "wrongsecret")
machine.wait_for_console_text("error: access denied.")
grub_select_all_configurations()
with subtest("Valid credentials are accepted"):
grub_login_as("alice", "supersecret")
machine.send_chars("\n") # press enter to boot
machine.wait_for_console_text("Linux version")
with subtest("Machine boots correctly"):
machine.wait_for_unit("multi-user.target")
'';
})