瀏覽代碼

Ask for the hypervisor's sudo password too

Almost everything the provisioner does on a hypervisor needs root.  Ours have
the login set up with passwordless sudo, so there has never been anything to
ask for; anybody else's run reaches the first privileged thing it does, finds
sudo wants a password, and dies there -- several minutes in, on a prompt nobody
will ever see, because sudo over ssh has no terminal to ask at.

So the dialog asks, and says it can be left empty, because empty is the right
answer for a hypervisor set up the way ours are.  It is remembered alongside the
passphrase and unlocked by the same code, and a typed one beats a stored one --
somebody typing it has a reason to, and it is usually that the stored one
stopped working.

Both now go down the provisioner's stdin as a credentials block rather than the
passphrase going on its own, which is what --credentials over there reads.  A
value with a line break in it is refused rather than escaped: the format is one
'name: value' per line, so a newline is a second line, and the second line could
name a credential nobody asked us to send.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADwKSaBSvUTdA3RtGQeNau
George Baugh 1 月之前
父節點
當前提交
5ddc8e17e3

+ 22 - 11
Readme.md

@@ -191,17 +191,25 @@ run whose status still says 'running' but whose process is gone reads as
 interrupted rather than as going on forever -- that is a guess from the only
 evidence there is, and it is the honest one.
 
-bin/provision asks for your KeePass passphrase on stdin, because every recipe
-inherits secret: values from _base.  The form asks you for it per run and pipes it
-to that one process; it is not written to the config, the log, or the process
-table.  The alternative would be keeping the master password for every secret you
-hold in a file the webserver can read.
-
-Typing it every time has its own cost -- a passphrase you have to have to hand is
-one you write down somewhere -- so you can tick "remember it" and have it stored
-in your vault instead.  After that the button asks for a TOTP code, which is
-spent on that one reprovision and exchanged for the passphrase.  See Stored
-Secrets, below.
+The button asks for two passwords, and neither is written to the config, the log,
+or the process table -- they go down the provisioner's stdin as a credentials
+block and nowhere else.
+
+The first is your KeePass passphrase, because every recipe inherits secret: values
+from _base.  The alternative would be keeping the master password for every secret
+you hold in a file the webserver can read.
+
+The second is your sudo password on the hypervisor, and it is optional because it
+is usually unnecessary: give that login passwordless sudo and there is nothing to
+ask for.  Where you cannot, sudo over ssh has no terminal to ask at, so a run that
+needs one and was not given one dies several minutes in on a prompt nobody will
+ever see.  Leave the field empty if this does not apply to you.
+
+Typing them every time has its own cost -- a passphrase you have to have to hand
+is one you write down somewhere -- so you can tick "remember these" and have them
+stored in your vault instead.  After that the button asks for a TOTP code, which
+is spent on that one reprovision and exchanged for both.  See Stored Secrets,
+below.
 
 Stored Secrets
 ==============
@@ -210,6 +218,9 @@ Some of what tCMS does for you needs a password that is not tCMS's.  /secrets is
 where each user keeps those, and what is kept there is sealed with a key the
 database does not contain, so a stolen `config/auth.db` is ciphertext.
 
+Reprovisioning uses two of them, `provisioner` and `provisioner_sudo`, and the
+reprovision dialog is the easy way to put them there.
+
 AES-256-GCM per row, under a key derived for that row from a fresh random salt,
 with the username and the secret's name bound in as associated data -- so a row
 cannot be lifted into somebody else's account or renamed into being the answer to

+ 112 - 42
lib/Trog/DataSource/ProvisionedVirt.pm

@@ -64,10 +64,12 @@ shell, so a guest name is an argument and can never be a command.
 our $recipe_dir = 'recipes.d';
 our $log_dir    = 'logs/reprovision';
 
-# What a stored provisioning passphrase is called in a user's vault.  One name
-# per person rather than one per guest: it is the passphrase to their secrets
-# database, and there is one of those.
+# What the provisioner's two passwords are called in a user's vault.  One name
+# per person rather than one per guest: the passphrase is to their own secrets
+# database, and the sudo password is theirs on the hypervisor rather than
+# anything to do with a particular guest.
 our $secret_name = 'provisioner';
+our $sudo_name   = 'provisioner_sudo';
 
 # Where an installation's own files live, which is trog-provisioner's rule
 # rather than ours -- see its Trog::Config.
@@ -114,8 +116,9 @@ sub posts ( $series, $query ) {
     # Asked once for the page rather than once per guest, and asked rather than
     # opened: what the form needs to know is which box to draw.
     my $user = $query->{user} // '';
-    $config->{can_remember} = ( $user                   && Trog::Vault::has_key() )                  ? 1 : 0;
-    $config->{remembered}   = ( $config->{can_remember} && Trog::Vault::has( $user, $secret_name ) ) ? 1 : 0;
+    $config->{can_remember}    = ( $user                   && Trog::Vault::has_key() )                  ? 1 : 0;
+    $config->{remembered}      = ( $config->{can_remember} && Trog::Vault::has( $user, $secret_name ) ) ? 1 : 0;
+    $config->{sudo_remembered} = ( $config->{can_remember} && Trog::Vault::has( $user, $sudo_name ) )   ? 1 : 0;
 
     return map { _with_recipe( $_, $config ) } @guests;
 }
@@ -144,8 +147,9 @@ sub _with_recipe ( $post, $config ) {
     $post->{can_reprovision} =
       ( $post->{has_recipe} && $config->{trog_provisioner} && !$post->{is_self} ) ? 1 : 0;
 
-    # Which of the two things the button has to ask for.
+    # Which of the things the button has to ask for.
     $post->{passphrase_remembered} = $config->{remembered};
+    $post->{sudo_remembered}       = $config->{sudo_remembered};
     $post->{can_remember}          = $config->{can_remember};
 
     my $status = status($domain);
@@ -356,9 +360,10 @@ recipe and then builds the machine.
 
     domain      which guest.  Required, and validated as a hostname.
     user        who asked.  For the log, and whose vault to look in.
-    passphrase  the KeePass passphrase the recipe's secrets need, on stdin.
-    totp        a code, instead of the passphrase, when they have stored one.
-    remember    store the passphrase they just typed, so that next time is a code.
+    passphrase  the KeePass passphrase the recipe's secrets need.
+    sudo        their sudo password on the hypervisor, if it wants one.
+    totp        a code, instead of those, when they have been stored.
+    remember    store what they just typed, so that next time is a code.
 
 Returns immediately.  Provisioning a machine takes minutes and an HTTP worker
 does not have minutes, so the work is handed to a detached process and the
@@ -367,11 +372,20 @@ logs/reprovision/$domain.log, which is what _log_tail reads back onto the page.
 
 =head3 WHY IT ASKS FOR ANYTHING AT ALL
 
+Two passwords, for two different reasons.
+
 Every recipe inherits secret: values from _base, so the provisioner opens a
-KeePass database on its way past and wants the passphrase to it on stdin.  The
+KeePass database on its way past and wants the passphrase to it.  The
 alternative to asking is keeping the master password for every secret an
 operator holds in a file the webserver can read, which is worse than asking.
 
+And almost everything it does on the hypervisor needs root.  Where the login
+there has passwordless sudo -- which is how ours are set up, and what we would
+recommend -- there is nothing to ask for.  Where it does not, sudo over ssh has
+no terminal to ask at, and a run that finds that out dies several minutes in on
+a prompt nobody will ever see.  So it is asked for here instead, and left empty
+by anybody who does not need it.
+
 Asking every time has its own cost, though: a passphrase somebody has to have to
 hand is a passphrase that gets written down, and then it is in a text file
 instead of a config file, which is not an improvement.  So it can be stored --
@@ -382,8 +396,9 @@ without their having to be holding anything.
 The code is spent either way, so it authorizes this one reprovision rather than
 every reprovision inside its window.
 
-Whichever it arrives as, the passphrase goes down the child's stdin and is
-written nowhere: not the log, not the process table, not the configuration.
+However they arrive, they go down the child's stdin as a credentials block --
+see Trog::Credentials in trog-provisioner -- and are written nowhere: not the
+log, not the process table, not the configuration.
 
 =cut
 
@@ -405,44 +420,60 @@ sub reprovision (%args) {
     my $recipe = _recipe_path( $config, $safe );
     return ( 0, "there is no recipe for '$safe' in $config->{recipes}" ) unless $recipe;
 
-    my ( $passphrase, $why ) = _passphrase(%args);
-    return ( 0, $why ) unless defined $passphrase;
+    my ( $credentials, $why ) = _credentials(%args);
+    return ( 0, $why ) unless $credentials;
+
+    my $unsendable = _sendable($credentials);
+    return ( 0, $unsendable ) if $unsendable;
 
     # One at a time.  Two provisions of one machine racing each other is not
     # something anybody should be able to start by double clicking.
     my $running = status($safe);
     return ( 0, 'a reprovision of this guest is already running' ) if $running && $running->{state} eq 'running';
 
-    my @command = ( "$config->{trog_provisioner}/bin/provision", $safe );
+    # --credentials, because nothing in there reads stdin unless it is told to.
+    my @command = ( "$config->{trog_provisioner}/bin/provision", '--credentials', $safe );
     return ( 0, "$command[0] is not there to run" ) unless -x $command[0];    ## no critic (ProhibitFiletest_rwxRWX) -- refusing early beats finding out mid-provision
 
     INFO( "Reprovision of '$safe' requested by " . ( $args{user} // 'somebody' ) );
 
-    my ( $ok, $failed ) = _spawn( $safe, $passphrase, $config->{trog_provisioner}, \@command, $args{user} );
+    my ( $ok, $failed ) = _spawn( $safe, $credentials, $config->{trog_provisioner}, \@command, $args{user} );
     return ( 0, $failed ) unless $ok;
 
     return ( 1, "reprovision started; watch $log_dir/$safe.log" );
 }
 
-=head2 _passphrase(%args) = ($passphrase, $why)
+=head2 _credentials(%args) = ($credentials, $why)
+
+What to hand the provisioner, out of whatever the form sent: a hashref of the
+names Trog::Credentials knows, or undef and a reason.
 
-The passphrase to hand the provisioner, out of whichever of the two things the
-form sent.
+A code is only ever exchanged for passwords that are already stored -- it is
+proof of presence, not a password, and there is nothing to derive from six
+digits.  Typed passwords are used as they are, and stored on the way past if
+they asked for that, so the next run is a code.
 
-A code is only ever exchanged for a passphrase that is already stored -- it is
-proof of presence, not a password, and there is nothing to derive from it.  A
-typed passphrase is used as it is, and stored on the way past if they asked for
-it to be, so that the next run is a code.
+The two are independent.  A sudo password typed into the form wins over a stored
+one, because somebody typing it has a reason to; and a hypervisor with
+passwordless sudo simply never has one, in the form or the vault, which is the
+ordinary case and not a missing answer.
 
-Undef with a reason for anything else, and the reason is what the person sees:
-this is the message that has to distinguish 'that code is stale' from 'there is
-nothing stored to unlock'.
+The reason, when there is one, is what the person reads -- so it has to tell
+'that code is stale' apart from 'there is nothing stored to unlock'.
 
 =cut
 
-sub _passphrase (%args) {
+sub _credentials (%args) {
     my $user = $args{user} // '';
 
+    my %credentials;
+
+    # What they typed, as opposed to what came back out of the vault.  Only the
+    # typed ones are worth filing away, and only they can be: a value fetched
+    # from the vault is already in it.
+    my %typed;
+    $typed{sudo} = $args{sudo} if length( $args{sudo} // '' );
+
     if ( length( $args{totp} // '' ) ) {
         return ( undef, 'a code identifies somebody, and nobody is logged in' ) unless $user;
 
@@ -460,25 +491,56 @@ sub _passphrase (%args) {
         return ( undef, $why ) unless $ok;
 
         my $stored = Trog::Vault::get( $user, $secret_name );
-        return ( undef,   "the stored provisioning passphrase for $user could not be read" ) unless defined $stored && length($stored);
-        return ( $stored, '' );
+        return ( undef, "the stored provisioning passphrase for $user could not be read" ) unless defined $stored && length($stored);
+        $credentials{keepass} = $stored;
+    }
+    else {
+        return ( undef, 'a passphrase is required, as the provisioner asks for one' ) unless length( $args{passphrase} // '' );
+        $credentials{keepass} = $args{passphrase};
+        $typed{keepass}       = $args{passphrase};
     }
 
-    return ( undef, 'a passphrase is required, as the provisioner asks for one' ) unless length( $args{passphrase} // '' );
+    # A typed sudo password beats a stored one -- somebody typing it has a
+    # reason to -- and no sudo password at all is the ordinary answer, for a
+    # hypervisor whose login has passwordless sudo.
+    my $sudo = $typed{sudo} // ( $user ? Trog::Vault::get( $user, $sudo_name ) : undef );
+    $credentials{sudo} = $sudo if defined $sudo && length($sudo);
 
-    if ( $args{remember} && $user ) {
-        my ( $ok, $why ) = Trog::Vault::set( $user, $secret_name, $args{passphrase} );
+    _remember( $user, \%typed ) if $args{remember} && $user && %typed;
 
-        # Said rather than refused: they asked for a reprovision and gave us
-        # what it needs, and failing to file the passphrase away is not a
-        # reason not to do the thing they asked for.
-        WARN("Could not remember the provisioning passphrase for $user: $why") unless $ok;
+    return ( \%credentials, '' );
+}
+
+# One 'name: value' per line is the format, so a value with a newline in it is
+# two lines -- and the second one could name a credential nobody asked us to
+# send.  Refused rather than escaped or trimmed: no password anybody meant to
+# type has a newline in the middle of it, and quietly changing what somebody
+# typed into their own password field is worse than saying no.
+sub _sendable ($credentials) {
+    foreach my $name ( sort keys %$credentials ) {
+        next unless $credentials->{$name} =~ m/[\r\n]/;
+        return "the $name has a line break in it, which is not something this can send";
     }
+    return '';
+}
 
-    return ( $args{passphrase}, '' );
+# File away what they typed, so the next run is a code.
+#
+# Said rather than refused when it does not work: they asked for a reprovision
+# and gave us what it needs, and failing to keep a copy is not a reason to not do
+# the thing they asked for.
+sub _remember ( $user, $credentials ) {
+    my %vault_name = ( keepass => $secret_name, sudo => $sudo_name );
+
+    foreach my $name ( sort keys %$credentials ) {
+        my ( $ok, $why ) = Trog::Vault::set( $user, $vault_name{$name}, $credentials->{$name} );
+        WARN("Could not remember the provisioner's $name for $user: $why") unless $ok;
+    }
+
+    return;
 }
 
-=head2 _spawn($domain, $passphrase, $dir, $command, $user)
+=head2 _spawn($domain, $credentials, $dir, $command, $user)
 
 Fork the provisioner off where nothing is waiting for it.
 
@@ -491,9 +553,16 @@ It runs through open() with a list, so there is no shell between us and the
 program, and the guest name is an argument rather than a thing that could be
 read as one.
 
+The passwords go down its stdin as a credentials block, which is
+Trog::Credentials' format over there: one C<name: value> per line, and a blank
+line to say that is all of them.  A value with a newline in it would be two
+lines, and the second could name a credential we were not asked to send, so one
+is refused rather than escaped -- there is no password anybody meant to type
+that has a newline in the middle of it.
+
 =cut
 
-sub _spawn ( $domain, $passphrase, $dir, $command, $user ) {
+sub _spawn ( $domain, $credentials, $dir, $command, $user ) {
     my $log = "$log_dir/$domain.log";
 
     local $@;
@@ -546,9 +615,10 @@ sub _spawn ( $domain, $passphrase, $dir, $command, $user ) {
         POSIX::_exit(1);
     }
 
-    # What the provisioner prompts for on its way to the secrets database.
-    # Closing this instead would make anything that asks hang forever.
-    print {$stdin} "$passphrase\n";
+    # What the provisioner would otherwise have nobody to ask for.  Closed
+    # afterwards, so the read on the far side ends rather than waiting on us.
+    print {$stdin} "$_: $credentials->{$_}\n" foreach sort keys %$credentials;
+    print {$stdin} "\n";
     close($stdin);
 
     if ($?) {

+ 6 - 5
lib/Trog/Routes/HTML.pm

@@ -2537,10 +2537,10 @@ Trog::DataSource::ProvisionedVirt::reprovision.
 This is not a recoverable operation, so read that function before changing
 anything here.  It refuses for the guest tCMS is itself running on.
 
-What the provisioner needs to open its secrets database arrives with the request
-and never lands in this hash: either a passphrase, which is used and forgotten
-unless they asked for it to be kept, or a TOTP code, which is spent and
-exchanged for the passphrase they kept last time.
+What the provisioner needs arrives with the request and never lands in this hash:
+the passphrase to its secrets database, the sudo password on the hypervisor if
+that login has no passwordless sudo, or a TOTP code which is spent and exchanged
+for whichever of those they kept last time.
 
 =cut
 
@@ -2559,6 +2559,7 @@ sub guest_reprovision ($query) {
         domain     => $query->{guest},
         user       => $query->{user},
         passphrase => $query->{passphrase},
+        sudo       => $query->{sudo},
         totp       => $query->{totp},
         remember   => $query->{remember},
     );
@@ -2567,7 +2568,7 @@ sub guest_reprovision ($query) {
     # handed to renderers, and neither of these has any business in any of that.
     # The code as much as the passphrase -- it is spent by now, but a spent code
     # in a log is still a code somebody wrote down.
-    delete $query->{$_} foreach qw{passphrase totp};
+    delete $query->{$_} foreach qw{passphrase sudo totp};
 
     return _feedback_redirect( $query, $to, $ok ? 0 : 1, ( $query->{guest} // 'guest' ) . ": $message" );
 }

+ 84 - 14
t/Trog-DataSource-ProvisionedVirt.t

@@ -94,8 +94,8 @@ $log->redefine( INFO => sub { note(shift) } );
 our @SPAWNED;
 $log->redefine(
     _spawn => sub {
-        my ( $domain, $passphrase, $dir, $command, $user ) = @_;
-        push( @SPAWNED, { domain => $domain, passphrase => $passphrase, dir => $dir, command => $command, user => $user } );
+        my ( $domain, $credentials, $dir, $command, $user ) = @_;
+        push( @SPAWNED, { domain => $domain, credentials => $credentials, dir => $dir, command => $command, user => $user } );
         return ( 1, 'started' );
     }
 );
@@ -211,11 +211,12 @@ subtest 'what it would run' => sub {
     # configuration from its recipe and then builds the machine.
     is( $run->{dir}, "$ROOT/trog-provisioner", 'run from the checkout' );
     like( $run->{command}[0], qr{/bin/provision$}, 'and it is bin/provision' );
-    is( $run->{command}[1], 'guest.example.com', 'passed the guest as an argument' );
+    is( $run->{command}[1], '--credentials',     'told that its passwords are coming on stdin' );
+    is( $run->{command}[2], 'guest.example.com', 'and passed the guest as an argument' );
 
     # Arguments, not a command line: there is no shell between us and this, so a
     # guest name can never be read as one.
-    is( scalar( @{ $run->{command} } ), 2, 'a program and one argument -- nothing to be parsed' );
+    is( scalar( @{ $run->{command} } ), 3, 'a program, a flag and one argument -- nothing to be parsed' );
 };
 
 subtest 'a stored passphrase, and a code instead of one' => sub {
@@ -236,8 +237,8 @@ subtest 'a stored passphrase, and a code instead of one' => sub {
 
     # Reprovision the old way, and ask for it to be kept.
     ( $ok, $why ) = reprovision( domain => 'guest.example.com', passphrase => 'correct horse', remember => 1 );
-    is( $ok,                     1,               'a typed passphrase still provisions' ) or diag($why);
-    is( $SPAWNED[0]{passphrase}, 'correct horse', 'and is what reaches the provisioner' );
+    is( $ok,                               1,               'a typed passphrase still provisions' ) or diag($why);
+    is( $SPAWNED[0]{credentials}{keepass}, 'correct horse', 'and is what reaches the provisioner' );
 
     ($post) = Trog::DataSource::ProvisionedVirt::posts( {}, { user => 'admin' } );
     is( $post->{passphrase_remembered}, 1, 'after which the form asks for a code instead' );
@@ -249,8 +250,8 @@ subtest 'a stored passphrase, and a code instead of one' => sub {
     my $code = $totp->expected_totp_code( time() );
 
     ( $ok, $why ) = reprovision( domain => 'guest.example.com', passphrase => undef, totp => $code );
-    is( $ok,                     1,               'a code provisions' ) or diag($why);
-    is( $SPAWNED[0]{passphrase}, 'correct horse', 'handing the provisioner the passphrase that was stored' );
+    is( $ok,                               1,               'a code provisions' ) or diag($why);
+    is( $SPAWNED[0]{credentials}{keepass}, 'correct horse', 'handing the provisioner the passphrase that was stored' );
 
     # Spent, so it is worth this one reprovision and not every reprovision
     # somebody can click inside the window.
@@ -262,6 +263,72 @@ subtest 'a stored passphrase, and a code instead of one' => sub {
     Trog::Vault::forget( 'admin', $Trog::DataSource::ProvisionedVirt::secret_name );
 };
 
+subtest 'the sudo password on the hypervisor' => sub {
+    no warnings qw{once};
+
+    $virt->redefine( posts => sub { return ( { title => 'guest.example.com', domain => 'guest.example.com', is_self => 0 } ) } );
+    Trog::Vault::forget( 'admin', $Trog::DataSource::ProvisionedVirt::secret_name );
+    Trog::Vault::forget( 'admin', $Trog::DataSource::ProvisionedVirt::sudo_name );
+
+    my ($post) = Trog::DataSource::ProvisionedVirt::posts( {}, { user => 'admin' } );
+    is( $post->{sudo_remembered}, 0, 'with none stored the form asks for one' );
+
+    # The ordinary case, and the reason the field is not required: the login on
+    # the hypervisor has passwordless sudo, so there is nothing to send.
+    my ( $ok, $why ) = reprovision( domain => 'guest.example.com', passphrase => 'correct horse' );
+    is( $ok, 1, 'a reprovision with no sudo password still runs' ) or diag($why);
+    ok( !exists $SPAWNED[0]{credentials}{sudo}, 'and sends none, rather than sending an empty one' );
+
+    # And the case this is for: a hypervisor that wants one.  sudo over ssh has
+    # no terminal to ask at, so the run would otherwise die minutes in.
+    ( $ok, $why ) = reprovision( domain => 'guest.example.com', passphrase => 'correct horse', sudo => 'hunter2' );
+    is( $ok,                               1,               'one with a sudo password runs too' ) or diag($why);
+    is( $SPAWNED[0]{credentials}{sudo},    'hunter2',       'and it reaches the provisioner' );
+    is( $SPAWNED[0]{credentials}{keepass}, 'correct horse', 'alongside the passphrase, not instead of it' );
+
+    # Kept, so the next run is a code and neither has to be typed again.
+    ( $ok, $why ) = reprovision( domain => 'guest.example.com', passphrase => 'correct horse', sudo => 'hunter2', remember => 1 );
+    is( $ok, 1, 'both can be remembered' ) or diag($why);
+    ($post) = Trog::DataSource::ProvisionedVirt::posts( {}, { user => 'admin' } );
+    is( $post->{passphrase_remembered}, 1, 'after which the passphrase is stored' );
+    is( $post->{sudo_remembered},       1, 'and so is the sudo password' );
+
+    my ( $uri, $qr, $failure, $message, $totp ) = Trog::Auth::totp( 'admin', 'example.com' );
+    require Trog::SQLite;
+    Trog::SQLite::dbh( 'schema/auth.schema', 'config/auth.db' )->do("DELETE FROM totp_spent WHERE username='admin'");
+
+    ( $ok, $why ) = reprovision( domain => 'guest.example.com', passphrase => undef, totp => $totp->expected_totp_code( time() ) );
+    is( $ok,                               1,               'a code unlocks the pair' ) or diag($why);
+    is( $SPAWNED[0]{credentials}{keepass}, 'correct horse', 'the passphrase' );
+    is( $SPAWNED[0]{credentials}{sudo},    'hunter2',       'and the sudo password' );
+
+    # Typed beats stored: somebody typing it has a reason to, and it is usually
+    # that the stored one stopped working.
+    Trog::SQLite::dbh( 'schema/auth.schema', 'config/auth.db' )->do("DELETE FROM totp_spent WHERE username='admin'");
+    ( $ok, $why ) = reprovision( domain => 'guest.example.com', passphrase => undef, sudo => 'changed it', totp => $totp->expected_totp_code( time() ) );
+    is( $ok,                            1,            'a code and a typed sudo password together' ) or diag($why);
+    is( $SPAWNED[0]{credentials}{sudo}, 'changed it', 'send what was typed' );
+
+    Trog::Vault::forget( 'admin', $Trog::DataSource::ProvisionedVirt::secret_name );
+    Trog::Vault::forget( 'admin', $Trog::DataSource::ProvisionedVirt::sudo_name );
+};
+
+subtest 'a password with a line break in it is refused' => sub {
+
+    # One 'name: value' per line is the format on the far side, so a newline in
+    # a value is a second line -- and the second line could name a credential
+    # nobody asked us to send.
+    my ( $ok, $why ) = reprovision( domain => 'guest.example.com', passphrase => "correct horse\nsudo: injected" );
+    is( $ok, 0, 'a passphrase carrying a line break does not run' );
+    like( $why, qr/line break/, 'saying so' );
+    is_deeply( \@SPAWNED, [], 'having run nothing' );
+
+    ( $ok, $why ) = reprovision( domain => 'guest.example.com', passphrase => 'correct horse', sudo => "hunter2\nkeepass: not this" );
+    is( $ok, 0, 'and neither does a sudo password carrying one' );
+    like( $why, qr/line break/, 'saying so too' );
+    is_deeply( \@SPAWNED, [], 'having run nothing' );
+};
+
 subtest 'and when the vault key has gone' => sub {
     no warnings qw{once};
 
@@ -303,8 +370,8 @@ subtest 'and when the vault key has gone' => sub {
     is( $ok, 1, 'the code was not spent on being told no' ) or diag($why);
 
     ( $ok, $why ) = reprovision( domain => 'guest.example.com', passphrase => 'typed it again', remember => 1 );
-    is( $ok,                     1,                'and the passphrase still provisions' ) or diag($why);
-    is( $SPAWNED[0]{passphrase}, 'typed it again', 'with what was typed' );
+    is( $ok,                               1,                'and the passphrase still provisions' ) or diag($why);
+    is( $SPAWNED[0]{credentials}{keepass}, 'typed it again', 'with what was typed' );
 
     ($post) = Trog::DataSource::ProvisionedVirt::posts( {}, { user => 'admin' } );
     is( $post->{passphrase_remembered}, 1, 'stored again under the key that is here now' );
@@ -497,11 +564,14 @@ subtest 'the route' => sub {
     # The passphrase must not survive the request: this hash gets cloned, logged
     # around and handed to renderers.
     @SPAWNED = ();
-    my $query = { user => 'admin', user_acls => ['admin'], tpsgi => $tpsgi, guest => 'guest.example.com', passphrase => 'hunter2', to => '/vms' };
+    my $query = { user => 'admin', user_acls => ['admin'], tpsgi => $tpsgi, guest => 'guest.example.com', passphrase => 'hunter2', sudo => 'letmein', to => '/vms' };
     Trog::Routes::HTML::guest_reprovision($query);
-    is( scalar(@SPAWNED),        1,         'an admin gets their run' );
-    is( $SPAWNED[0]{passphrase}, 'hunter2', 'the passphrase reaches the provisioner' );
-    ok( !exists $query->{passphrase}, 'and is gone from the query afterwards' );
+    is( scalar(@SPAWNED),                  1,         'an admin gets their run' );
+    is( $SPAWNED[0]{credentials}{keepass}, 'hunter2', 'the passphrase reaches the provisioner' );
+    is( $SPAWNED[0]{credentials}{sudo},    'letmein', 'and so does the sudo password' );
+
+    # This hash is cloned, logged around and handed to renderers.
+    ok( !exists $query->{$_}, "the $_ is gone from the query afterwards" ) foreach qw{passphrase sudo totp};
 
     # The log route.  It lives in logs/ rather than under www/, and the output
     # of a provisioner is a fine place for a hostname or an IP plan to turn up.

+ 5 - 0
www/styles/screen.css

@@ -536,3 +536,8 @@ pre {
     display: flex;
     gap: .5rem;
 }
+/* Says a field can be left empty, without it reading as a second question. */
+.guest-reprovision-optional {
+    font-weight: normal;
+    opacity: .7;
+}

+ 17 - 5
www/templates/html/components/forms/provisioned_guests.tx

@@ -132,21 +132,33 @@
                             TOTP code
                             <input required autofocus class="cooltext" type="password" name="totp" autocomplete="off"
                                    inputmode="numeric" pattern="[0-9]{6}"
-                                   title="unlocks the provisioning passphrase you stored; spent on this run, so wait for the next code if you just used one" />
+                                   title="unlocks the provisioning passwords you stored; spent on this run, so wait for the next code if you just used one" />
                         </label>
                         : } else {
                         <label>
                             Secrets passphrase
                             <input required autofocus class="cooltext" type="password" name="passphrase" autocomplete="off"
-                                   title="the provisioner asks for this on stdin" />
+                                   title="the passphrase to the KeePass database the recipes take their secrets from" />
                         </label>
-                        : if ( $post.can_remember ) {
+                        : }
+                        : # Asked for separately, and only when there is not one
+                        : # stored: sudo on the hypervisor has no terminal to ask
+                        : # at, so a run that needs it and was not given it dies
+                        : # minutes in.  Empty is the ordinary answer, since the
+                        : # login there usually has passwordless sudo.
+                        : if ( !$post.sudo_remembered ) {
+                        <label>
+                            Hypervisor sudo password <span class="guest-reprovision-optional">&mdash; leave empty for passwordless sudo</span>
+                            <input class="cooltext" type="password" name="sudo" autocomplete="off"
+                                   title="your sudo password on the hypervisor.  Only needed where that login has not been given passwordless sudo." />
+                        </label>
+                        : }
+                        : if ( $post.can_remember && ( !$post.passphrase_remembered || !$post.sudo_remembered ) ) {
                         <label class="guest-reprovision-remember">
                             <input type="checkbox" name="remember" value="1" />
-                            Remember it, and ask me for a TOTP code next time
+                            Remember these, and ask me for a TOTP code next time
                         </label>
                         : }
-                        : }
                         <div class="guest-reprovision-actions">
                             <button class="coolbutton" type="button"
                                     command="hide-popover" commandfor="reprovision-<: $post.id :>"