[Libguestfs] [PATCH] customize: Add --ssh-inject option for injecting SSH keys.

Pino Toscano
Mon, 03 Nov 2014 18:36:21 -0000

This adds a customize option:

  virt-customize --ssh-inject USER
  virt-customize --ssh-inject USER:string:KEY_STRING
  virt-customize --ssh-inject USER:file:FILENAME

(ditto for virt-builder and virt-sysprep)

In each case this injects into the guest user USER
a) the current (host) user's ssh pubkey
b) the key specified as KEY_STRING
c) the key in FILENAME
adding it to ~USER/.ssh/authorized_keys in the guest.

For example:

  virt-builder fedora-20 --ssh-inject root

will add the local user's ssh pubkey into the root account of the
newly created guest.  Or:

  virt-customize -a disk.img \
     --ssh-inject 'mary:string:ssh-rsa AAAA.... mary at localhost'

adds the given ssh pubkey to mary's account in the guest.

This doesn't set the SELinux labels correctly on newly created files
and directories, so you have to use --selinux-relabel (probably we
should fix this as part of the general effort to fix SELinux
relabelling).  However it should preserve the labels if the
~/.ssh/authorized_keys file already exists.

Most of this work is based on a patch sent to the mailing list by
Richard W.M. Jones <rjones at redhat.com>:
https://www.redhat.com/archives/libguestfs/2014-November/msg00000.html
---
 builder/Makefile.am        |   1 +
 builder/cmdline.ml         |   4 +-
 builder/virt-builder.pod   |  31 +++++++++++
 customize/Makefile.am      |   3 +
 customize/customize_run.ml |   8 +++
 customize/ssh_key.ml       | 133 +++++++++++++++++++++++++++++++++++++++++++++
 customize/ssh_key.mli      |  31 +++++++++++
 generator/customize.ml     |  38 ++++++++++++-
 po/POTFILES-ml             |   1 +
 sysprep/Makefile.am        |   1 +
 v2v/Makefile.am            |   1 +
 11 files changed, 248 insertions(+), 4 deletions(-)
 create mode 100644 customize/ssh_key.ml
 create mode 100644 customize/ssh_key.mli

diff --git a/builder/Makefile.am b/builder/Makefile.am
index 414279f..206abce 100644
--- a/builder/Makefile.am
+++ b/builder/Makefile.am
@@ -111,6 +111,7 @@ deps = \
 	$(top_builddir)/customize/crypt-c.o \
 	$(top_builddir)/customize/crypt.cmx \
 	$(top_builddir)/customize/password.cmx \
+	$(top_builddir)/customize/ssh_key.cmx \
 	$(top_builddir)/customize/customize_cmdline.cmx \
 	$(top_builddir)/customize/customize_run.cmx \
 	$(top_builddir)/fish/guestfish-uri.o \
diff --git a/builder/cmdline.ml b/builder/cmdline.ml
index c0584f7..e21d5bb 100644
--- a/builder/cmdline.ml
+++ b/builder/cmdline.ml
@@ -306,8 +306,8 @@ read the man page virt-builder(1).
           | `Command _ | `InstallPackages _ | `Script _ | `Update -> true
           | `Delete _ | `Edit _ | `FirstbootCommand _ | `FirstbootPackages _
           | `FirstbootScript _ | `Hostname _ | `Link _ | `Mkdir _
-          | `Password _ | `RootPassword _ | `Scrub _ | `Timezone _ | `Upload _
-          | `Write _ | `Chmod _ -> false
+          | `Password _ | `RootPassword _ | `Scrub _ | `SSHInject _
+          | `Timezone _ | `Upload _ | `Write _ | `Chmod _ -> false
         ) ops.ops in
         if requires_execute_on_guest then
           error (f_"sorry, cannot run commands on a guest with a different
architecture");
diff --git a/builder/virt-builder.pod b/builder/virt-builder.pod
index 993e92c..82533d6 100644
--- a/builder/virt-builder.pod
+++ b/builder/virt-builder.pod
@@ -774,6 +774,37 @@ If C</tmp> or C<C:\Temp> is missing.
 If you don't want the log file to appear in the final image, then
 use the I<--no-logfile> command line option.

+=head2 SSH KEYS
+
+The I<--ssh-inject> option is used to inject ssh keys for users in
+the guest, so they can login without supplying a password.
+
+The C<SELECTOR> part of the option value is optional; in this case,
+I<--ssh-inject> C<USER> means that we look in the I<current>
+user's C<~/.ssh> directory to find the default public ID file.  That
+key is uploaded.  "default public ID" is the I<default_ID_file>
file
+described in L<ssh-copy-id(1)>.
+
+If specified, the C<SELECTOR> can be in one of the following formats:
+
+=over 4
+
+=item B<--ssh-inject> USER:file:FILENAME
+
+Read the ssh key from C<FILENAME>.  C<FILENAME> is usually a
I<.pub>
+file.
+
+=item B<--ssh-inject> USER:string:KEY_STRING
+
+Use the specified C<KEY_STRING>.  C<KEY_STRING> is usually a public
+string like I<ssh-rsa AAAA.... user at localhost>.
+
+=back
+
+In any case, the C<~USER/.ssh> directory and the
+C<~USER/.ssh/authorized_keys> file will be created if not existing
+already.
+
 =head2 INSTALLATION PROCESS

 When you invoke virt-builder, installation proceeds as follows:
diff --git a/customize/Makefile.am b/customize/Makefile.am
index 60e2091..56c5ad5 100644
--- a/customize/Makefile.am
+++ b/customize/Makefile.am
@@ -55,6 +55,8 @@ SOURCES = \
 	perl_edit.mli \
 	random_seed.ml \
 	random_seed.mli \
+	ssh_key.ml \
+	ssh_key.mli \
 	timezone.ml \
 	timezone.mli \
 	urandom.ml \
@@ -92,6 +94,7 @@ ocaml_modules = \
 	password \
 	perl_edit \
 	random_seed \
+	ssh_key \
 	timezone \
 	customize_cmdline \
 	customize_run \
diff --git a/customize/customize_run.ml b/customize/customize_run.ml
index 51b218a..09ada7d 100644
--- a/customize/customize_run.ml
+++ b/customize/customize_run.ml
@@ -232,6 +232,14 @@ exec >>%s 2>&1
       msg (f_"Scrubbing: %s") path;
       g#scrub_file path

+    | `SSHInject (user, selector) ->
+      (match g#inspect_get_type root with
+      | "linux" | "freebsd" | "netbsd" |
"openbsd" | "hurd" ->
+        msg (f_"SSH key inject: %s") user;
+        Ssh_key.do_ssh_inject_unix g user selector
+      | _ ->
+        warning (f_"SSH key could be injected for this type of
guest"))
+
     | `Timezone tz ->
       msg (f_"Setting the timezone: %s") tz;
       if not (Timezone.set_timezone g root tz) then
diff --git a/customize/ssh_key.ml b/customize/ssh_key.ml
new file mode 100644
index 0000000..09664bf
--- /dev/null
+++ b/customize/ssh_key.ml
@@ -0,0 +1,133 @@
+(* virt-customize
+ * Copyright (C) 2014 Red Hat Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ *)
+
+open Common_gettext.Gettext
+open Common_utils
+
+open Customize_utils
+
+open Printf
+open Sys
+open Unix
+
+module G = Guestfs
+
+type ssh_key_selector +| SystemKey
+| KeyFile of string
+| KeyString of string
+
+let rec parse_selector arg +  parse_selector_list arg (string_nsplit
":" arg)
+
+and parse_selector_list orig_arg = function
+  | [] | [ "" ] ->
+    SystemKey
+  | [ "file"; f ] ->
+    KeyFile f
+  | [ "string"; s ] ->
+    KeyString s
+  | _ ->
+    error (f_"invalid ssh-inject selector '%s'; see the man
page") orig_arg
+
+(* Find the local [on the host] user's SSH public key.  See
+ * ssh-copy-id(1) default_ID_file for rationale.
+ *)
+let pubkey_re = Str.regexp "^id.*\\.pub$"
+let pubkey_ignore_re = Str.regexp ".*-cert\\.pub$"
+
+let local_user_ssh_pubkey () +  let home_dir +    try getenv "HOME"
+    with Not_found ->
+      error (f_"ssh-inject: $HOME environment variable is not set")
in
+  let ssh_dir = home_dir // ".ssh" in
+  let files = Sys.readdir ssh_dir in
+  let files = Array.to_list files in
+  let files = List.filter (
+    fun file ->
+      Str.string_match pubkey_re file 0 &&
+        not (Str.string_match pubkey_ignore_re file 0)
+  ) files in
+  if files = [] then
+    error (f_"ssh-inject: no public key file found in %s") ssh_dir;
+
+  (* Newest file. *)
+  let files = List.map (
+    fun file ->
+      let file = ssh_dir // file in
+      let stat = stat file in
+      (file, stat.st_mtime)
+  ) files in
+  let files = List.sort (fun (_,m1) (_,m2) -> compare m2 m1) files in
+
+  fst (List.hd files)
+
+let read_key file +  (* Read and return the public key. *)
+  let key = read_whole_file file in
+  if key = "" then
+    error (f_"ssh-inject: public key file (%s) is empty") file;
+  key
+
+let key_string_from_selector = function
+  | SystemKey ->
+    read_key (local_user_ssh_pubkey ())
+  | KeyFile f ->
+    read_key f
+  | KeyString s ->
+    if String.length s < 1 then
+      error (f_"ssh-inject: key is an empty string");
+    s
+
+(* Inject SSH key, where possible. *)
+let do_ssh_inject_unix (g : Guestfs.guestfs) user selector +  let key =
key_string_from_selector selector in
+  assert (String.length key > 0);
+
+  (* If the key doesn't have \n at the end, add it. *)
+  let len = String.length key in
+  let key = if key.[len-1] = '\n' then key else key ^ "\n" in
+
+  (* Get user's home directory. *)
+  g#aug_init "/" 0;
+  let home_dir +    try
+      let expr = sprintf "/files/etc/passwd/%s/home" user in
+      g#aug_get expr
+    with G.Error _ ->
+      error (f_"ssh-inject: the user %s does not exist on the guest")
+        user in
+  g#aug_close ();
+
+  (* Create ~user/.ssh if it doesn't exist. *)
+  let ssh_dir = sprintf "%s/.ssh" home_dir in
+  if not (g#exists ssh_dir) then (
+    g#mkdir ssh_dir;
+    g#chmod 0o755 ssh_dir
+  );
+
+  (* Create ~user/.ssh/authorized_keys if it doesn't exist. *)
+  let auth_keys = sprintf "%s/authorized_keys" ssh_dir in
+  if not (g#exists auth_keys) then (
+    g#touch auth_keys;
+    g#chmod 0o644 auth_keys
+  );
+
+  (* Append the key. *)
+  g#write_append auth_keys key
diff --git a/customize/ssh_key.mli b/customize/ssh_key.mli
new file mode 100644
index 0000000..3223e55
--- /dev/null
+++ b/customize/ssh_key.mli
@@ -0,0 +1,31 @@
+(* virt-customize
+ * Copyright (C) 2014 Red Hat Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ *)
+
+type ssh_key_selector +| SystemKey                    (* Default key from the
user in the system, in
+                                * the style of ssh-copy-id(1)/default_ID_file.
+                                *)
+| KeyFile of string            (* Key from the specified file. *)
+| KeyString of string          (* Key specified as string. *)
+
+val parse_selector : string -> ssh_key_selector
+(** Parse the selector field in --ssh-ibject.  Note this
+    doesn't parse the username part.  Exits if the format is not valid. *)
+
+val do_ssh_inject_unix : Guestfs.guestfs -> string -> ssh_key_selector
-> unit
+(** ... *)
diff --git a/generator/customize.ml b/generator/customize.ml
index 8642a54..82ecb79 100644
--- a/generator/customize.ml
+++ b/generator/customize.ml
@@ -42,6 +42,7 @@ and op_type  | TargetLinks of string                 (*
target:link[:link...] *)
 | PasswordSelector of string            (* password selector *)
 | UserPasswordSelector of string        (* user:selector *)
+| SSHKeySelector of string              (* user:selector *)

 let ops = [
   { op_name = "chmod";
@@ -260,6 +261,22 @@ It cannot delete directories, only regular files.
 =back";
   };

+  { op_name = "ssh-inject";
+    op_type = SSHKeySelector "USER[:SELECTOR]";
+    op_discrim = "`SSHInject";
+    op_shortdesc = "Inject a public key into the guest";
+    op_pod_longdesc = "\
+Inject an ssh key so the given C<USER> will be able to log in over
+ssh without supplying a password.  The C<USER> must exist already
+in the guest.
+
+See L<virt-builder(1)/SSH KEYS> for the format of
+the C<SELECTOR> field.
+
+You can have multiple I<--ssh-inject> options, for different users
+and also for more keys for each user."
+  };
+
   { op_name = "timezone";
     op_type = String "TIMEZONE";
     op_discrim = "`Timezone";
@@ -539,6 +556,19 @@ let rec argspec ()        pr "      s_\"%s\"
^ \" \" ^ s_\"%s\"\n" v shortdesc;
       pr "    ),\n";
       pr "    Some %S, %S;\n" v longdesc
+    | { op_type = SSHKeySelector v; op_name = name; op_discrim = discrim;
+        op_shortdesc = shortdesc; op_pod_longdesc = longdesc } ->
+      pr "    (\n";
+      pr "      \"--%s\",\n" name;
+      pr "      Arg.String (\n";
+      pr "        fun s ->\n";
+      pr "          let user, selstr = string_split \":\" s
in\n";
+      pr "          let sel = Ssh_key.parse_selector selstr in\n";
+      pr "          ops := %s (user, sel) :: !ops\n" discrim;
+      pr "      ),\n";
+      pr "      s_\"%s\" ^ \" \" ^
s_\"%s\"\n" v shortdesc;
+      pr "    ),\n";
+      pr "    Some %S, %S;\n" v longdesc
   ) ops;

   List.iter (
@@ -606,6 +636,10 @@ type ops = {
         op_name = name } ->
       pr "  | %s of string * Password.password_selector\n      (* --%s %s
*)\n"
         discrim name v
+    | { op_type = SSHKeySelector v; op_discrim = discrim;
+        op_name = name } ->
+      pr "  | %s of string * Ssh_key.ssh_key_selector\n      (* --%s %s
*)\n"
+        discrim name v
   ) ops;
   pr "]\n";

@@ -631,7 +665,7 @@ let generate_customize_synopsis_pod ()        | { op_type =
Unit; op_name = n } ->
         n, sprintf "[--%s]" n
       | { op_type = String v | StringPair v | StringList v | TargetLinks v
-            | PasswordSelector v | UserPasswordSelector v;
+            | PasswordSelector v | UserPasswordSelector v | SSHKeySelector v;
           op_name = n } ->
         n, sprintf "[--%s %s]" n v
     ) ops @
@@ -671,7 +705,7 @@ let generate_customize_options_pod ()        | { op_type =
Unit; op_name = n; op_pod_longdesc = ld } ->
         n, sprintf "B<--%s>" n, ld
       | { op_type = String v | StringPair v | StringList v | TargetLinks v
-            | PasswordSelector v | UserPasswordSelector v;
+            | PasswordSelector v | UserPasswordSelector v | SSHKeySelector v;
           op_name = n; op_pod_longdesc = ld } ->
         n, sprintf "B<--%s> %s" n v, ld
     ) ops @
diff --git a/po/POTFILES-ml b/po/POTFILES-ml
index a3086eb..b6d88b0 100644
--- a/po/POTFILES-ml
+++ b/po/POTFILES-ml
@@ -25,6 +25,7 @@ customize/hostname.ml
 customize/password.ml
 customize/perl_edit.ml
 customize/random_seed.ml
+customize/ssh_key.ml
 customize/timezone.ml
 customize/urandom.ml
 mllib/JSON.ml
diff --git a/sysprep/Makefile.am b/sysprep/Makefile.am
index 6553c9c..17fe612 100644
--- a/sysprep/Makefile.am
+++ b/sysprep/Makefile.am
@@ -102,6 +102,7 @@ deps = \
 	$(top_builddir)/customize/firstboot.cmx \
 	$(top_builddir)/customize/perl_edit-c.o \
 	$(top_builddir)/customize/perl_edit.cmx \
+	$(top_builddir)/customize/ssh_key.cmx \
 	$(top_builddir)/customize/customize_cmdline.cmx \
 	$(top_builddir)/customize/customize_run.cmx \
 	$(top_builddir)/fish/guestfish-uri.o \
diff --git a/v2v/Makefile.am b/v2v/Makefile.am
index b4bb9cc..9217777 100644
--- a/v2v/Makefile.am
+++ b/v2v/Makefile.am
@@ -139,6 +139,7 @@ BOBJECTS = \
 	$(top_builddir)/customize/perl_edit.cmo \
 	$(top_builddir)/customize/crypt.cmo \
 	$(top_builddir)/customize/password.cmo \
+	$(top_builddir)/customize/ssh_key.cmo \
 	$(top_builddir)/customize/customize_run.cmo \
 	$(SOURCES_ML:.ml=.cmo)
 XOBJECTS = $(BOBJECTS:.cmo=.cmx)
--
1.9.3