diff mbox series

[bug#55912,v2] home: Add OpenSSH service.

Message ID 20220615202951.22501-1-ludo@gnu.org
State Accepted
Headers show
Series [bug#55912,v2] home: Add OpenSSH service. | expand

Checks

Context Check Description
cbaines/comparison success View comparision
cbaines/git branch success View Git branch
cbaines/applying patch success View Laminar job
cbaines/issue success View issue

Commit Message

Ludovic Courtès June 15, 2022, 8:29 p.m. UTC
* gnu/home/services/ssh.scm: New file.
* gnu/local.mk (GNU_SYSTEM_MODULES): Add it.
* po/guix/POTFILES.in: Add it.
* doc/guix.texi (Secure Shell): New section.
---
 doc/guix.texi             | 176 +++++++++++++++++++++++++-
 gnu/home/services/ssh.scm | 254 ++++++++++++++++++++++++++++++++++++++
 gnu/local.mk              |   1 +
 po/guix/POTFILES.in       |   1 +
 4 files changed, 431 insertions(+), 1 deletion(-)
 create mode 100644 gnu/home/services/ssh.scm

Hello!

Changes compared to v1:

  • Use *unspecified* instead of 'disabled for unspecified field
    values, relying on the new semantics that Attila introduced.

  • No longer add the ‘openssh’ package to the profile.

  • Support non-ASCII file names in ‘file-join’.

  • Use a “natural number” type for the ‘port’ field.

Thoughts?

Ludo’.


base-commit: 8a04ac4b2f5d356719d896536dabc95a9520c938

Comments

M June 15, 2022, 8:47 p.m. UTC | #1
Ludovic Courtès schreef op wo 15-06-2022 om 22:29 [+0200]:
> +  (computed-file name
> +                 (with-imported-modules '((guix build utils))
> +                   #~(begin
> +                       (use-modules (guix build utils))
> +
> +                       ;; Support non-ASCII file names.
> +                       (setenv "GUIX_LOCPATH"
> +                               #+(file-append glibc-utf8-locales
> +                                              "/lib/locale"))
> +                       (setlocale LC_ALL "en_US.utf8")

For robustness, I think it would be best to move this locale
initialisation code to the implementation of 'computed-file' itself, to
eliminate this potential pitfall entirely.

Except for 'racket' and package transformations, this does not seem to
used by any package definition (except via meson when cross-compiling),
so it doesn't seem like this would entail a world-rebuild
(unverified!).

Greetings,
Maxime.
Ludovic Courtès June 16, 2022, 10:47 a.m. UTC | #2
Maxime Devos <maximedevos@telenet.be> skribis:

> Ludovic Courtès schreef op wo 15-06-2022 om 22:29 [+0200]:
>> +  (computed-file name
>> +                 (with-imported-modules '((guix build utils))
>> +                   #~(begin
>> +                       (use-modules (guix build utils))
>> +
>> +                       ;; Support non-ASCII file names.
>> +                       (setenv "GUIX_LOCPATH"
>> +                               #+(file-append glibc-utf8-locales
>> +                                              "/lib/locale"))
>> +                       (setlocale LC_ALL "en_US.utf8")
>
> For robustness, I think it would be best to move this locale
> initialisation code to the implementation of 'computed-file' itself, to
> eliminate this potential pitfall entirely.

I’d rather have ‘computed-file’ do just what it’s documented to do; I
don’t think this kind of thing belongs there.  (It’s beyond the scope of
this patch set too.)

Ideally Guile would just do the right thing without us fiddling with
locales.  That is, it would default to UTF-8 rather than ASCII.

Thanks,
Ludo’.
M June 16, 2022, 12:16 p.m. UTC | #3
> I’d rather have ‘computed-file’ do just what it’s documented to do;
> I don’t think this kind of thing belongs there (It’s beyond the scope
> of this patch set too.)

The documentation of ‘computed-file’ can be modified to document it
uses a non-broken file name encoding instead of the broken default. 
Though something for a separate patch I suppose.

Ludovic Courtès schreef op do 16-06-2022 om 12:47 [+0200]:
> Ideally Guile would just do the right thing without us fiddling with
> locales.  That is, it would default to UTF-8 rather than ASCII.

I did a quick test, and apparently Guile calls nl_langinfo to determine
the encoding, which returns ANSI_X3.4-1968, because glibc defaults to
the C locale.  So unless you want to change the encoding of the C
locale or change the default locale or override glibc's choice of
default locale in Guile, I don't think there's anything to change in
Guile?

Greetins,
Maxime.
M June 16, 2022, 12:31 p.m. UTC | #4
Maxime Devos schreef op do 16-06-2022 om 14:16 [+0200]:
> Ludovic Courtès schreef op do 16-06-2022 om 12:47 [+0200]:
> > Ideally Guile would just do the right thing without us fiddling
> > with locales.  That is, it would default to UTF-8 rather than
> > ASCII.

Somewhat related, I could look into separating locales from the file
name encoding in Guile (with a parameter object or something) later? 
Not a solution as-is, but would be convenient in many places ...

Greetings,
Maxime.
Ludovic Courtès June 16, 2022, 4:18 p.m. UTC | #5
Maxime Devos <maximedevos@telenet.be> skribis:

> I did a quick test, and apparently Guile calls nl_langinfo to determine
> the encoding, which returns ANSI_X3.4-1968, because glibc defaults to
> the C locale.  So unless you want to change the encoding of the C
> locale or change the default locale or override glibc's choice of
> default locale in Guile, I don't think there's anything to change in
> Guile?

Glibc 2.35 includes the “C.UTF-8” locale; I don’t know if it’s the
default, but it will likely help.

> Somewhat related, I could look into separating locales from the file
> name encoding in Guile (with a parameter object or something) later? 
> Not a solution as-is, but would be convenient in many places ...

Yes, that too.  In (guix build syscalls), there’s a variant of ‘scandir’
for instance that is locale-independent and decodes file names as UTF-8.
Nowadays that’s probably the most sensible option.

In Guile proper, it would be nice if there were a ‘%file-name-encoding’
fluid.

Thanks,
Ludo’.
M June 16, 2022, 4:45 p.m. UTC | #6
Ludovic Courtès schreef op do 16-06-2022 om 18:18 [+0200]:
> In Guile proper, it would be nice if there were a ‘%file-name-encoding’
> fluid.

I was more thinking of a %file-name-encoding parameter (instead of a
fluid), but that's what I had in mind.

Greetings,
Maxime.
Ludovic Courtès June 17, 2022, 12:32 p.m. UTC | #7
Maxime Devos <maximedevos@telenet.be> skribis:

> Ludovic Courtès schreef op do 16-06-2022 om 18:18 [+0200]:
>> In Guile proper, it would be nice if there were a ‘%file-name-encoding’
>> fluid.
>
> I was more thinking of a %file-name-encoding parameter (instead of a
> fluid), but that's what I had in mind.

Yeah, could be (for “historical reasons”, similar interfaces such as
‘%default-port-encoding’ were fluids, that’s why I mentioned that).

Ludo’.
Philip McGrath June 17, 2022, 12:42 p.m. UTC | #8
On Thursday, June 16, 2022 12:45:30 PM EDT Maxime Devos wrote:
> Ludovic Courtès schreef op do 16-06-2022 om 18:18 [+0200]:
> > In Guile proper, it would be nice if there were a ‘%file-name-encoding’
> > fluid.
> 
> I was more thinking of a %file-name-encoding parameter (instead of a
> fluid), but that's what I had in mind.
> 

I think the problem goes deeper than that: an R6RS string is a fixed-length 
sequence of Unicode scalar values, but a path on a Unix-like system is a 
sequence of non-null bytes, and on Windows is a sequence of UTF-16 code units 
with possibly unpaired surrogates (aka WTF-16 [1]). That is, there are some 
valid paths that can not be represented as Scheme strings.

Racket has a really nice path datatype that handles these subtleties (there 
are many bad headaches if you want to be portable to Windows) while allowing 
an ergonomic use of strings for the common case. [2]

Zuo has a more minimal path API which takes advantage of the fact that a Zuo 
string is like a Scheme bytevector. [3] It doesn't handle all of the 
complexity managed by the Racket path type, but focuses on the subset of paths 
applicable to a build environment. The implementation of all of Zuo is a 
single C file. [4]

-Philip

[1]: https://simonsapin.github.io/wtf-8/
[2]: https://docs.racket-lang.org/reference/pathutils.html
[3]: https://docs.racket-lang.org/zuo/zuo-base.html#%28part._.Paths%29
[4]: https://github.com/racket/racket/blob/master/racket/src/zuo/zuo.c
M June 17, 2022, 8:56 p.m. UTC | #9
Philip McGrath schreef op vr 17-06-2022 om 08:42 [-0400]:
> I think the problem goes deeper than that: an R6RS string is a fixed-
> length sequence of Unicode scalar values, but a path on a Unix-like
> system is a sequence of non-null bytes,

That's one of the possibilities I know about.  Treating that case as
‘ISO-88591-1’ would be sufficient for Guix, albeit conceptually
incorrect.

> and on Windows is a sequence of UTF-16 code units 
> with possibly unpaired surrogates (aka WTF-16 [1]).  
> That is, there are some
> valid paths that can not be represented as Scheme strings.

Nasty. Didn't know about that.  I'll skip that one for now though
(I don't think Guile uses the right Windows APIs for that, it just
calls 'open' and 'stat' and the like).

> Racket has a really nice path datatype that handles these subtleties
> (there  are many bad headaches if you want to be portable to Windows)
> while allowing an ergonomic use of strings for the common case. [2]

For now(later), I'll just stick to support overriding the file name
encoding, a proper separate path datatype can be added later.  E.g.,
open-file can be changed to support both filenames as strings (to be
encoded by the file name encoding) or as an instance of the path
datatype.  Except for rebase conflicts, this seems rather orthogonal to
me.

Greetings,
Maxime.
Ludovic Courtès June 18, 2022, 9:41 p.m. UTC | #10
Ludovic Courtès <ludo@gnu.org> skribis:

> * gnu/home/services/ssh.scm: New file.
> * gnu/local.mk (GNU_SYSTEM_MODULES): Add it.
> * po/guix/POTFILES.in: Add it.
> * doc/guix.texi (Secure Shell): New section.

Pushed yesterday as 7f208f68dea828fe02718ca8ce81d5975136cff8.

Thanks, Maxime!

Ludo’.
diff mbox series

Patch

diff --git a/doc/guix.texi b/doc/guix.texi
index 143bf36403..35a70ba56d 100644
--- a/doc/guix.texi
+++ b/doc/guix.texi
@@ -39043,6 +39043,7 @@  services)}.
 * Shells: Shells Home Services.          POSIX shells, Bash, Zsh.
 * Mcron: Mcron Home Service.             Scheduled User's Job Execution.
 * Shepherd: Shepherd Home Service.       Managing User's Daemons.
+* SSH: Secure Shell.                     Setting up the secure shell client.
 * Desktop: Desktop Home Services.        Services for graphical environments.
 @end menu
 @c In addition to that Home Services can provide
@@ -39363,7 +39364,7 @@  GNU@tie{}mcron, a daemon to run jobs at scheduled times (@pxref{Top,,,
 mcron, GNU@tie{}mcron}).  The information about system's mcron is
 applicable here (@pxref{Scheduled Job Execution}), the only difference
 for home services is that they have to be declared in a
-@code{home-envirnoment} record instead of an @code{operating-system}
+@code{home-environment} record instead of an @code{operating-system}
 record.
 
 @defvr {Scheme Variable} home-mcron-service-type
@@ -39431,6 +39432,179 @@  mechanism instead (@pxref{Shepherd Services}).
 @end table
 @end deftp
 
+@node Secure Shell
+@subsection Secure Shell
+
+@cindex secure shell client, configuration
+@cindex SSH client, configuration
+The @uref{https://www.openssh.com, OpenSSH package} includes a client,
+the @command{ssh} command, that allows you to connect to remote machines
+using the @acronym{SSH, secure shell} protocol.  With the @code{(gnu
+home services ssh)} module, you can set up OpenSSH so that it works in a
+predictable fashion, almost independently of state on the local machine.
+To do that, you instantiate @code{home-openssh-service-type} in your
+Home configuration, as explained below.
+
+@defvr {Scheme Variable} home-openssh-service-type
+This is the type of the service to set up the OpenSSH client.  It takes
+care of several things:
+
+@itemize
+@item
+providing a @file{~/.ssh/config} file based on your configuration so
+that @command{ssh} knows about hosts you regularly connect to and their
+associated parameters;
+
+@item
+providing a @file{~/.ssh/authorized_keys}, which lists public keys that
+the local SSH server, @command{sshd}, may accept to connect to this user
+account;
+
+@item
+optionally providing a @file{~/.ssh/known_hosts} file so that @file{ssh}
+can authenticate hosts you connect to.
+@end itemize
+
+Here is a sample configuration you could add to the @code{services}
+field of your @code{home-environment}:
+
+@lisp
+(home-openssh-configuration
+ (hosts (list (openssh-host (name "ci.guix.gnu.org")
+                            (user "charlie"))
+              (openssh-host (name "chbouib")
+                            (host-name "chbouib.example.org")
+                            (user "supercharlie")
+                            (port 10022))))
+ (authorized-keys (list (local-file "alice.pub"))))
+@end lisp
+
+The example above lists two hosts and their parameters.  For instance,
+running @command{ssh chbouib} will automatically connect to
+@code{chbouib.example.org} on port 10022, logging in as user
+@samp{supercharlie}.  Further, it marks the public key in
+@file{alice.pub} as authorized for incoming connections.
+
+The value associated with a @code{home-openssh-service-type} instance
+must be a @code{home-openssh-configuration} record, as describe below.
+@end defvr
+
+@deftp {Data Type} home-openssh-configuration
+This is the datatype representing the OpenSSH client and server
+configuration in one's home environment.  It contains the following
+fields:
+
+@table @asis
+@item @code{hosts} (default: @code{'()})
+A list of @code{openssh-host} records specifying host names and
+associated connection parameters (see below).  This host list goes into
+@file{~/.ssh/config}, which @command{ssh} reads at startup.
+
+@item @code{known-hosts} (default: @code{*unspecified*})
+This must be either:
+
+@itemize
+@item
+@code{*unspecified*}, in which case @code{home-openssh-service-type}
+leaves it up to @command{ssh} and to the user to maintain the list of
+known hosts at @file{~/.ssh/known_hosts}, or
+
+@item
+a list of file-like objects, in which case those are concatenated and
+emitted as @file{~/.ssh/known_hosts}.
+@end itemize
+
+The @file{~/.ssh/known_hosts} contains a list of host name/host key
+pairs that allow @command{ssh} to authenticate hosts you connect to and
+to detect possible impersonation attacks.  By default, @command{ssh}
+updates it in a @dfn{TOFU, trust-on-first-use} fashion, meaning that it
+records the host's key in that file the first time you connect to it.
+This behavior is preserved when @code{known-hosts} is set to
+@code{*unspecified*}.
+
+If you instead provide a list of host keys upfront in the
+@code{known-hosts} field, your configuration becomes self-contained and
+stateless: it can be replicated elsewhere or at another point in time.
+Preparing this list can be relatively tedious though, which is why
+@code{*unspecified*} is kept as a default.
+
+@item @code{authorized-keys} (default: @code{'()})
+This must be a list of file-like objects, each of which containing an
+SSH public key that should be authorized to connect to this machine.
+
+Concretely, these files are concatenated and made available as
+@file{~/.ssh/authorized_keys}.  If an OpenSSH server, @command{sshd}, is
+running on this machine, then it @emph{may} take this file into account:
+this is what @command{sshd} does by default, but be aware that it can
+also be configured to ignore it.
+@end table
+@end deftp
+
+@c %start of fragment
+
+@deftp {Data Type} openssh-host
+Available @code{openssh-host} fields are:
+
+@table @asis
+@item @code{name} (type: string)
+Name of this host declaration.
+
+@item @code{host-name} (type: maybe-string)
+Host name---e.g., @code{"foo.example.org"} or @code{"192.168.1.2"}.
+
+@item @code{address-family} (type: address-family)
+Address family to use when connecting to this host: one of
+@code{AF_INET} (for IPv4 only), @code{AF_INET6} (for IPv6 only), or
+@code{*unspecified*} (allowing any address family).
+
+@item @code{identity-file} (type: maybe-string)
+The identity file to use---e.g., @code{"/home/charlie/.ssh/id_ed25519"}.
+
+@item @code{port} (type: maybe-natural-number)
+TCP port number to connect to.
+
+@item @code{user} (type: maybe-string)
+User name on the remote host.
+
+@item @code{forward-x11?} (default: @code{#f}) (type: boolean)
+Whether to forward remote client connections to the local X11 graphical
+display.
+
+@item @code{forward-x11-trusted?} (default: @code{#f}) (type: boolean)
+Whether remote X11 clients have full access to the original X11
+graphical display.
+
+@item @code{forward-agent?} (default: @code{#f}) (type: boolean)
+Whether the authentication agent (if any) is forwarded to the remote
+machine.
+
+@item @code{compression?} (default: @code{#f}) (type: boolean)
+Whether to compress data in transit.
+
+@item @code{proxy-command} (type: maybe-string)
+The command to use to connect to the server.  As an example, a command
+to connect via an HTTP proxy at 192.0.2.0 would be: @code{"nc -X connect
+-x 192.0.2.0:8080 %h %p"}.
+
+@item @code{host-key-algorithms} (type: maybe-string-list)
+The list of accepted host key algorithms---e.g.,
+@code{'("ssh-ed25519")}.
+
+@item @code{accepted-key-types} (type: maybe-string-list)
+The list of accepted user public key types.
+
+@item @code{extra-content} (default: @code{""}) (type: raw-configuration-string)
+Extra content appended as-is to this @code{Host} block in
+@file{~/.ssh/config}.
+
+@end table
+
+@end deftp
+
+
+@c %end of fragment
+
+
 @node Desktop Home Services
 @subsection Desktop Home Services
 
diff --git a/gnu/home/services/ssh.scm b/gnu/home/services/ssh.scm
new file mode 100644
index 0000000000..ff2992766c
--- /dev/null
+++ b/gnu/home/services/ssh.scm
@@ -0,0 +1,254 @@ 
+;;; GNU Guix --- Functional package management for GNU
+;;; Copyright © 2022 Ludovic Courtès <ludo@gnu.org>
+;;;
+;;; This file is part of GNU Guix.
+;;;
+;;; GNU Guix 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 3 of the License, or (at
+;;; your option) any later version.
+;;;
+;;; GNU Guix 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 GNU Guix.  If not, see <http://www.gnu.org/licenses/>.
+
+(define-module (gnu home services ssh)
+  #:use-module (guix gexp)
+  #:use-module (guix records)
+  #:use-module (guix diagnostics)
+  #:use-module (guix i18n)
+  #:use-module (gnu services)
+  #:use-module (gnu services configuration)
+  #:use-module (guix modules)
+  #:use-module (gnu home services)
+  #:use-module ((gnu home services utils)
+                #:select (object->camel-case-string))
+  #:autoload   (gnu packages base) (glibc-utf8-locales)
+  #:use-module (srfi srfi-1)
+  #:use-module (srfi srfi-34)
+  #:use-module (srfi srfi-35)
+  #:use-module (ice-9 match)
+  #:export (home-openssh-configuration
+            home-openssh-configuration-authorized-keys
+            home-openssh-configuration-known-hosts
+            home-openssh-configuration-hosts
+
+            openssh-host
+            openssh-host-host-name
+            openssh-host-identity-file
+            openssh-host-name
+            openssh-host-port
+            openssh-host-user
+            openssh-host-forward-x11?
+            openssh-host-forward-x11-trusted?
+            openssh-host-forward-agent?
+            openssh-host-compression?
+            openssh-host-proxy-command
+            openssh-host-host-key-algorithms
+            openssh-host-accepted-key-types
+            openssh-host-extra-content
+
+            home-openssh-service-type))
+
+(define (serialize-field-name name)
+  (match name
+    ('accepted-key-types "PubkeyAcceptedKeyTypes")
+    (_
+     (let ((name (let ((str (symbol->string name)))
+                   (if (string-suffix? "?" str)
+                       (string->symbol (string-drop-right str 1))
+                       name))))
+       (object->camel-case-string name 'upper)))))
+
+(define (serialize-string field value)
+  (string-append "  " (serialize-field-name field)
+                 " " value "\n"))
+
+(define (address-family? obj)
+  (memv obj (list *unspecified* AF_INET AF_INET6)))
+
+(define (serialize-address-family field family)
+  (if (unspecified? family)
+      ""
+      (string-append "  " (serialize-field-name field) " "
+                     (cond ((= family AF_INET) "inet")
+                           ((= family AF_INET6) "inet6")
+                           ;; The 'else' branch is unreachable.
+                           (else (raise (condition (&error)))))
+                     "\n")))
+
+(define (natural-number? obj)
+  (and (integer? obj) (exact? obj) (> obj 0)))
+
+(define (serialize-natural-number field value)
+  (string-append "  " (serialize-field-name field) " "
+                 (number->string value) "\n"))
+
+(define (serialize-boolean field value)
+  (string-append "  " (serialize-field-name field) " "
+                 (if value "yes" "no") "\n"))
+
+(define-maybe string)
+(define-maybe natural-number)
+
+(define (serialize-raw-configuration-string field value)
+  (string-append value "\n"))
+(define raw-configuration-string? string?)
+
+(define (string-list? lst)
+  (and (pair? lst) (every string? lst)))
+(define (serialize-string-list field lst)
+  (string-append "  " (serialize-field-name field) " "
+                 (string-join lst ",") "\n"))
+
+(define-maybe string-list)
+
+(define-configuration openssh-host
+  (name
+   (string)
+   "Name of this host declaration.")
+  (host-name
+   maybe-string
+   "Host name---e.g., @code{\"foo.example.org\"} or @code{\"192.168.1.2\"}.")
+  (address-family
+   address-family
+   "Address family to use when connecting to this host: one of
+@code{AF_INET} (for IPv4 only), @code{AF_INET6} (for IPv6 only), or
+@code{*unspecified*} (allowing any address family).")
+  (identity-file
+   maybe-string
+   "The identity file to use---e.g.,
+@code{\"/home/charlie/.ssh/id_ed25519\"}.")
+  (port
+   maybe-natural-number
+   "TCP port number to connect to.")
+  (user
+   maybe-string
+   "User name on the remote host.")
+  (forward-x11?
+   (boolean #f)
+   "Whether to forward remote client connections to the local X11 graphical
+display.")
+  (forward-x11-trusted?
+   (boolean #f)
+   "Whether remote X11 clients have full access to the original X11 graphical
+display.")
+  (forward-agent?
+   (boolean #f)
+   "Whether the authentication agent (if any) is forwarded to the remote
+machine.")
+  (compression?
+   (boolean #f)
+   "Whether to compress data in transit.")
+  (proxy-command
+   maybe-string
+   "The command to use to connect to the server.  As an example, a command
+to connect via an HTTP proxy at 192.0.2.0 would be: @code{\"nc -X
+connect -x 192.0.2.0:8080 %h %p\"}.")
+  (host-key-algorithms
+   maybe-string-list
+   "The list of accepted host key algorithms---e.g.,
+@code{'(\"ssh-ed25519\")}.")
+  (accepted-key-types
+   maybe-string-list
+   "The list of accepted user public key types.")
+  (extra-content
+   (raw-configuration-string "")
+   "Extra content appended as-is to this @code{Host} block in
+@file{~/.ssh/config}."))
+
+(define (serialize-openssh-host config)
+  (define (openssh-host-name-field? field)
+    (eq? (configuration-field-name field) 'name))
+
+  (string-append
+   "Host " (openssh-host-name config) "\n"
+   (string-concatenate
+    (map (lambda (field)
+           ((configuration-field-serializer field)
+            (configuration-field-name field)
+            ((configuration-field-getter field) config)))
+         (remove openssh-host-name-field?
+                 openssh-host-fields)))))
+
+(define-record-type* <home-openssh-configuration>
+  home-openssh-configuration make-home-openssh-configuration
+  home-openssh-configuration?
+  (authorized-keys home-openssh-configuration-authorized-keys ;list of file-like
+                   (default '()))
+  (known-hosts     home-openssh-configuration-known-hosts ;unspec | list of file-like
+                   (default *unspecified*))
+  (hosts           home-openssh-configuration-hosts   ;list of <openssh-host>
+                   (default '())))
+
+(define (openssh-configuration->string config)
+  (string-join (map serialize-openssh-host
+                    (home-openssh-configuration-hosts config))
+               "\n"))
+
+(define* (file-join name files #:optional (delimiter " "))
+  "Return a file in the store called @var{name} that is the concatenation
+of all the file-like objects listed in @var{files}, with @var{delimited}
+inserted after each of them."
+  (computed-file name
+                 (with-imported-modules '((guix build utils))
+                   #~(begin
+                       (use-modules (guix build utils))
+
+                       ;; Support non-ASCII file names.
+                       (setenv "GUIX_LOCPATH"
+                               #+(file-append glibc-utf8-locales
+                                              "/lib/locale"))
+                       (setlocale LC_ALL "en_US.utf8")
+
+                       (call-with-output-file #$output
+                         (lambda (output)
+                           (for-each (lambda (file)
+                                       (call-with-input-file file
+                                         (lambda (input)
+                                           (dump-port input output)))
+                                       (display #$delimiter output))
+                                     '#$files)))))))
+
+(define (openssh-configuration-files config)
+  (let ((config (plain-file "ssh.conf"
+                            (openssh-configuration->string config)))
+        (known-hosts (home-openssh-configuration-known-hosts config))
+        (authorized-keys (file-join
+                          "authorized_keys"
+                          (home-openssh-configuration-authorized-keys config)
+                          "\n")))
+    `((".ssh/authorized_keys" ,authorized-keys)
+      ,@(if (unspecified? known-hosts)
+            '()
+            `((".ssh/known_hosts"
+               ,(file-join "known_hosts" known-hosts "\n"))))
+      (".ssh/config" ,config))))
+
+(define openssh-activation
+  (with-imported-modules (source-module-closure
+                          '((gnu build activation)))
+    #~(begin
+        (use-modules (gnu build activation))
+
+        ;; Make sure ~/.ssh is #o700.
+        (let* ((home (getenv "HOME"))
+               (dot-ssh (string-append home "/.ssh")))
+          (mkdir-p/perms dot-ssh (getpw (getuid)) #o700)))))
+
+(define home-openssh-service-type
+  (service-type
+   (name 'home-openssh)
+   (extensions
+    (list (service-extension home-files-service-type
+                             openssh-configuration-files)
+          (service-extension home-activation-service-type
+                             (const openssh-activation))))
+   (description "Configure the OpenSSH @acronym{SSH, secure shell} client
+by providing a @file{~/.ssh/config} file, which is honored by the OpenSSH
+client,@command{ssh}, and by other tools such as @command{guix deploy}.")
+   (default-value (home-openssh-configuration))))
diff --git a/gnu/local.mk b/gnu/local.mk
index 5a9edc16bb..372573d3c4 100644
--- a/gnu/local.mk
+++ b/gnu/local.mk
@@ -85,6 +85,7 @@  GNU_SYSTEM_MODULES =				\
   %D%/home/services/fontutils.scm		\
   %D%/home/services/shells.scm			\
   %D%/home/services/shepherd.scm		\
+  %D%/home/services/ssh.scm			\
   %D%/home/services/mcron.scm			\
   %D%/home/services/utils.scm			\
   %D%/home/services/xdg.scm			\
diff --git a/po/guix/POTFILES.in b/po/guix/POTFILES.in
index 6b8bd92bb7..201e5dcc87 100644
--- a/po/guix/POTFILES.in
+++ b/po/guix/POTFILES.in
@@ -6,6 +6,7 @@  gnu/services.scm
 gnu/system.scm
 gnu/services/shepherd.scm
 gnu/home/services.scm
+gnu/home/services/ssh.scm
 gnu/home/services/symlink-manager.scm
 gnu/system/file-systems.scm
 gnu/system/image.scm