Subject: Re: bug#56608: [PATCH] gnu: security: Add fail2ban-service-type.

From: Maxim Cournoyer <[email-masked]>
Date: Wed, 03 Aug 2022 12:09:16 -0400

Hi muradm,

muradm <[email-masked]> writes:

> * gnu/services/security.scm: New module.
> * gnu/local.mk: Add new security module.
> * doc/guix.text: Add fail2ban-service-type documentation.
> ---
>  doc/guix.texi             | 215 +++++++++++++++++++++++++
>  gnu/local.mk              |   2 +
>  gnu/services/security.scm | 328 ++++++++++++++++++++++++++++++++++++++
>  3 files changed, 545 insertions(+)
>  create mode 100644 gnu/services/security.scm
>
> diff --git a/doc/guix.texi b/doc/guix.texi
> index 8b09bcd4eb..1fc327c4bc 100644
> --- a/doc/guix.texi
> +++ b/doc/guix.texi
> @@ -36285,6 +36285,221 @@ Extra command line options for @code{nix-service-type}.
>  @end table
>  @end deftp
>
> +@cindex Fail2ban
> +@subsubheading Fail2ban service
> +
> +@uref{http://www.fail2ban.org/, @code{fail2ban}} scans log files
> +(e.g. @code{/var/log/apache/error_log}) and bans IPs that show the malicious
> +signs -- too many password failures, seeking for exploits, etc.
> +
> +@code{fail2ban} service is provided in @code{(gnu services security)} module.
> +
> +This is the type of the service that runs @code{fail2ban} daemon. It can be
> +used in various ways, which are:
> +
> +@itemize
> +
> +@item Explicit configuration
> +users are free to enable @code{fail2ban} configuration without strong
> +dependency.
> +
> +@item On-demand extending configuration
> +convenience @code{fail2ban-jail-service} function is provided, in order
> +to extend existing services on-demand.
> +
> +@item Permanent extending configuration
> +service developers may not @code{fail2ban-service-type} in service-type's
> +extensions.
> +
> +@end itemize
> +
> +@defvr {Scheme Variable} fail2ban-service-type
> +
> +This is the type of the service that runs @code{fail2ban} daemon. It can be
> +configured explicitly as following:
> +
> +@lisp
> +(append
> + (list
> +  ;; excplicit configuration, this way fail2ban daemon
> +  ;; will start and do its thing for sshd jail
> +  (service fail2ban-service-type
> +           (fail2ban-configuration
> +            (extra-jails
> +             (list
> +              (fail2ban-jail-configuration
> +               (name "sshd")
> +               (enabled #t))))))
> +  ;; there is no direct dependency with actual openssh
> +  ;; server configuration, it could even be omited here

                                               ^omitted

> +  (service openssh-service-type))
> + %base-services)
> +@end lisp
> +@end defvr
> +
> +@deffn {Scheme Procedure} fail2ban-jail-service @var{svc-type} @var{jail}
> +Return extended @var{svc-type} of @code{<service-type>} with added
> +@var{jail} of type @code{fail2ban-jail-configuration} extension
> +for @code{fail2ban-service-type}.
> +
> +For example:
> +
> +@lisp
> +(append
> + (list
> +  (service
> +   ;; using convenience function we can extend virutally

Please use full sentences for standalone (not inline)
comments.  Also, typo: virutally -> virtually.

> +   ;; any service type with any fail2ban jail
> +   ;; this way we don't have to explicitly add
> +   ;; (service fail2ban-service-type) to our configuration
> +   (fail2ban-jail-service
> +    openssh-service-type
> +    (fail2ban-jail-configuration
> +     (name "sshd")
> +     (enabled #t)))
> +   (openssh-configuration ...))))
> +@end lisp
> +@end deffn
> +
> +@deftp {Data Type} fail2ban-configuration
> +Configuration record for the @code{fail2ban-service-type}.
> +@table @asis
> +
> +@item @code{fail2ban} (default: @code{fail2ban})
> +The @code{fail2ban} package to use. It used for both binaries and
> +as base default configuration that will be extended with
> +@code{<fail2ban-jail-configuration>}s.
> +
> +@item @code{run-directory} (default: @file{"/var/run/fail2ban"})
> +State directory for @code{fail2ban} daemon.
> +
> +@item @code{jails} (default: @code{'()})
> +Instances of @code{<fail2ban-jail-configuration>} collected from
> +extensions.
> +
> +@item @code{extra-jails} (default: @code{'()})
> +Instances of @code{<fail2ban-jail-configuration>} provided by user
> +explicitly.
> +
> +@item @code{extra-content} (default: @code{""})
> +Extra raw content to add at the end of @file{jail.local}.
> +
> +@end table
> +@end deftp
> +
> +@deftp {Data Type} fail2ban-jail-configuration
> +@code{fail2ban} jail configuration to be added to @file{jail.local}.
> +@table @asis
> +
> +Fields with default value of @code{*unspecified*} will not be serialized
> +to configuration, thus default values of @code{fail2ban} will apply.

Perhaps this configuration should use the 'define-configuration'
mechanism, which allows to declare which fields can be left unspecified,
without *unspecified* being a visible part of the API (which is not
great, in my opinion).

> +For details of field meanings, please refer to @code{fail2ban} documentation.
> +
> +@item @code{name}
> +Required name of this jail configuration.
> +
> +@item @code{enabled} (default: @code{*unspecified*})
> +Either @code{#t} or @code{#f} for @samp{true} and @samp{false} respectively.
> +
> +@item @code{max-retry} (default: @code{*unspecified*})
> +Is the number of failures before a host get banned (e.g. @code{(max-retry 5)}).
> +
> +@item @code{max-matches} (default: @code{*unspecified*})
> +Is the number of matches stored in ticket  (resolvable via
> +tag @code{<matches>}) in action.
> +
> +@item @code{find-time} (default: @code{*unspecified*})
> +A host is banned if it has geneerated @code{max-retry} during the last
> +@code{find-time} seconds (e.g. @code{(find-time "10m")}).
> +
> +@item @code{ban-time} (default: @code{*unspecified*})
> +Is the number of seconds that a host is banned (e.g. @code{(ban-time "10m")}).
> +
> +@item @code{ban-time-increment} (default: @code{*unspecified*})
> +Allows to use database for searching of previously banned ip's to increase a
> +default ban time using special formula.
> +
> +@item @code{ban-time-factor} (default: @code{*unspecified*})
> +Is a coefficient to calculate exponent growing of the formula or common multiplier.
> +
> +@item @code{ban-time-formula} (default: @code{*unspecified*})
> +Used by default to calculate next value of ban time.
> +
> +@item @code{ban-time-multipliers} (default: @code{*unspecified*})
> +Used to calculate next value of ban time instead of formula.
> +
> +@item @code{ban-time-maxtime} (default: @code{*unspecified*})
> +Is the max number of seconds using the ban time can reach (doesn't grow further).
> +
> +@item @code{ban-time-rndtime} (default: @code{*unspecified*})
> +Is the max number of seconds using for mixing with random time
> +to prevent ``clever'' botnets calculate exact time IP can be unbanned again.
> +
> +@item @code{ban-time-overalljails} (default: @code{*unspecified*})
> +Either @code{#t} or @code{#f} for @samp{true} and @samp{false} respectively.
> +@itemize
> +@item @code{true} - specifies the search of IP in the database will be executed cross over all jails
> +@item @code{false} - only current jail of the ban IP will be searched
> +@end itemize
> +
> +@item @code{ignore-command} (default: @code{*unspecified*})
> +External command that will take an tagged arguments to ignore.
> +Note: while provided, currently unimplemented in the context of @code{guix}.
> +
> +@item @code{ignore-self} (default: @code{*unspecified*})
> +Specifies whether the local resp. own IP addresses should be ignored.
> +
> +@item @code{ignore-ip} (default: @code{'()})
> +Can be a list of IP addresses, CIDR masks or DNS hosts. @code{fail2ban}
> +will not ban a host which matches an address in this list
> +
> +@item @code{ignore-cache} (default: @code{*unspecified*})
> +
> +@item @code{filter} (default: @code{*unspecified*})
> +Defines the filter to use by the jail, using
> +@code{<fail2ban-jail-filter-configuration>}.
> +By default jails have names matching their filter name.
> +
> +@item @code{log-time-zone} (default: @code{*unspecified*})
> +
> +@item @code{log-encoding} (default: @code{*unspecified*})
> +Specifies the encoding of the log files handled by the jail.
> +Possible values: @code{'ascii}, @code{'utf-8}, @code{'auto}.
> +
> +@item @code{log-path} (default: @code{*unspecified*})
> +
> +@item @code{action} (default: @code{'()})
> +List of @code{<fail2ban-jail-action-configuration>}.
> +
> +@end table
> +@end deftp

See above comment about *unspecified*.

> +@deftp {Data Type} fail2ban-jail-filter-configuration
> +@code{fail2ban} jail filter configuration.
> +@table @asis
> +
> +@item @code{name}
> +Name part required.
> +
> +@item @code{mode} (default: @code{*unspecified*})
> +
> +@end table
> +@end deftp
> +
> +@deftp {Data Type} fail2ban-jail-action-configuration
> +@code{fail2ban} jail action configuration.
> +@table @asis
> +
> +@item @code{name}
> +Name part required.
> +
> +@item @code{arguments} (default: @code{'()})
> +Association list of key value pairs.
> +
> +@end table
> +@end deftp
> +
>  @node Setuid Programs
>  @section Setuid Programs
>
> diff --git a/gnu/local.mk b/gnu/local.mk
> index 07e3497d10..eef7d09fd4 100644
> --- a/gnu/local.mk
> +++ b/gnu/local.mk
> @@ -50,6 +50,7 @@
>  # Copyright © 2022 Daniel Meißner <[email-masked]>
>  # Copyright © 2022 Remco van 't Veer <[email-masked]>
>  # Copyright © 2022 Artyom V. Poptsov <[email-masked]>
> +# Copyright © 2022 muradm <[email-masked]>
>  #
>  # This file is part of GNU Guix.
>  #
> @@ -670,6 +671,7 @@ GNU_SYSTEM_MODULES =				\
>    %D%/services/nfs.scm			\
>    %D%/services/pam-mount.scm			\
>    %D%/services/science.scm			\
> +  %D%/services/security.scm			\
>    %D%/services/security-token.scm		\
>    %D%/services/shepherd.scm			\
>    %D%/services/sound.scm			\
> diff --git a/gnu/services/security.scm b/gnu/services/security.scm
> new file mode 100644
> index 0000000000..db95d68035
> --- /dev/null
> +++ b/gnu/services/security.scm
> @@ -0,0 +1,328 @@
> +;;; GNU Guix --- Functional package management for GNU
> +;;; Copyright © 2022 muradm <[email-masked]>
> +;;;
> +;;; 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 services security)
> +  #:use-module (guix gexp)
> +  #:use-module (guix records)
> +  #:use-module (gnu packages admin)
> +  #:use-module (ice-9 format)
> +  #:use-module (ice-9 match)
> +  #:use-module (srfi srfi-1)
> +  #:use-module (gnu services)
> +  #:use-module (gnu services shepherd)

Please re-order lexicographically.

> +  #:export (fail2ban-ignore-cache-configuration
> +            fail2ban-jail-filter-configuration
> +            fail2ban-jail-action-configuration
> +            fail2ban-jail-configuration
> +            fail2ban-configuration
> +            fail2ban-service-type
> +            fail2ban-jail-service))

Configuration and services are often separated by a blank line to ease
the reading.  I'd order them here as well.

> +(define (fail2ban-section->string name)
> +  (format #f "[~a]" name))
> +
> +(define fail2ban-backend->string
> +  (match-lambda
> +    ('auto "auto")
> +    ('pyinotify "pyinotify")
> +    ('gamin "gamin")
> +    ('polling "polling")
> +    ('systemd "systemd")
> +    (unknown (error (format #f "Unknown fail2ban backend: ~a" unknown)))))

This should use the (guix diagnostics), e.g. report-error module to
produce standardized errors, and possibly hints.  Errors and warnings
should not be full sentences, while hints should be, by convention.

> +(define fail2ban-log-encoding->string
> +  (match-lambda
> +    ('auto "auto")
> +    ('utf-8 "utf-8")
> +    ('ascii "ascii")
> +    (unknown (error (format #f "Unknown fail2ban log-encoding: ~a" unknown)))))
> +
> +(define-record-type* <fail2ban-ignore-cache-configuration>
> +  fail2ban-ignore-cache-configuration make-fail2ban-ignore-cache-configuration
> +  fail2ban-ignore-cache-configuration?
> +  (key                  fail2ban-ignore-cache-configuration-key)
> +  (max-count            fail2ban-ignore-cache-configuration-max-count)
> +  (max-time             fail2ban-ignore-cache-configuration-max-time))
> +
> +(define fail2ban-ignore-cache-configuration->string
> +  (match-lambda
> +    (($ <fail2ban-ignore-cache-configuration> key max-count max-time)
> +     (format #f "key=\"~a\", max-count=~d, max-time=~d" key max-count max-time))))
> +
> +(define-record-type* <fail2ban-jail-filter-configuration>
> +  fail2ban-jail-filter-configuration make-fail2ban-jail-filter-configuration
> +  fail2ban-jail-filter-configuration?
> +  (name                 fail2ban-jail-filter-configuration-name)
> +  (mode                 fail2ban-jail-filter-configuration-node
> +    (default *unspecified*)))
> +
> +(define fail2ban-jail-filter-configuration->string
> +  (match-lambda
> +    (($ <fail2ban-jail-filter-configuration> name mode)
> +     (format #f "~a~a"
> +             name (if (unspecified? mode) "" (format #f "[mode=~a]" mode))))))
> +
> +(define-record-type* <fail2ban-jail-action-configuration>
> +  fail2ban-jail-action-configuration make-fail2ban-jail-action-configuration
> +  fail2ban-jail-action-configuration?
> +  (name                  fail2ban-jail-action-configuration-name)
> +  (arguments             fail2ban-jail-action-configuration-arguments
> +                         (default '())))
> +
> +(define (fail2ban-arguments->string args)
> +  (let* ((multi-value
> +          (lambda (v)
> +            (format #f "\"~a\"" (string-join (map object->string v) ","))))
> +         (any-value
> +          (lambda (v)
> +            (if (list? v) (multi-value v) v)))
> +         (key-value
> +          (lambda (e)
> +            (format #f "~a=~a" (car e) (any-value (cdr e))))))
> +    (format #f "~a" (string-join (map key-value args) ","))))
> +
> +(define fail2ban-jail-action-configuration->string
> +  (match-lambda
> +    (($ <fail2ban-jail-action-configuration> name arguments)
> +     (format #f "~a~a"
> +             name (if (null? arguments) ""
> +                      (format #f "[~a]"
> +                              (fail2ban-arguments->string arguments)))))))
> +
> +(define-record-type* <fail2ban-jail-configuration>
> +  fail2ban-jail-configuration make-fail2ban-jail-configuration
> +  fail2ban-jail-configuration?
> +  (name                  fail2ban-jail-configuration-name)
> +  (enabled               fail2ban-jail-configuration-enabled
> +                         (default *unspecified*))
> +  (backend               fail2ban-jail-configuration-backend
> +                         (default *unspecified*))
> +  (max-retry             fail2ban-jail-configuration-max-retry
> +                         (default *unspecified*))
> +  (max-matches           fail2ban-jail-configuration-max-matches
> +                         (default *unspecified*))
> +  (find-time             fail2ban-jail-configuration-find-time
> +                         (default *unspecified*))
> +  (ban-time              fail2ban-jail-configuration-ban-time
> +                         (default *unspecified*))
> +  (ban-time-increment    fail2ban-jail-configuration-ban-time-increment
> +                         (default *unspecified*))
> +  (ban-time-factor       fail2ban-jail-configuration-ban-time-factor
> +                         (default *unspecified*))
> +  (ban-time-formula      fail2ban-jail-configuration-ban-time-formula
> +                         (default *unspecified*))
> +  (ban-time-multipliers  fail2ban-jail-configuration-ban-time-multipliers
> +                         (default *unspecified*))
> +  (ban-time-maxtime      fail2ban-jail-configuration-ban-time-maxtime
> +                         (default *unspecified*))
> +  (ban-time-rndtime      fail2ban-jail-configuration-ban-time-rndtime
> +                         (default *unspecified*))
> +  (ban-time-overalljails fail2ban-jail-configuration-ban-time-overalljails
> +                         (default *unspecified*))
> +  (ignore-command        fail2ban-jail-configuration-ignore-command
> +                         (default *unspecified*))
> +  (ignore-self           fail2ban-jail-configuration-ignore-self
> +                         (default *unspecified*))
> +  (ignore-ip             fail2ban-jail-configuration-ignore-ip
> +                         (default '()))
> +  (ignore-cache          fail2ban-jail-configuration-ignore-cache
> +                         (default *unspecified*))
> +  (filter                fail2ban-jail-configuration-filter
> +                         (default *unspecified*))
> +  (log-time-zone         fail2ban-jail-configuration-log-time-zone
> +                         (default *unspecified*))
> +  (log-encoding          fail2ban-jail-configuration-log-encoding
> +                         (default *unspecified*))
> +  (log-path              fail2ban-jail-configuration-log-path
> +                         (default *unspecified*))
> +  (action                fail2ban-jail-configuration-action
> +                         (default '())))
> +
> +(define fail2ban-jail-configuration->string
> +  (match-lambda
> +    (($ <fail2ban-jail-configuration> name enabled backend
> +                                      max-retry max-matches
> +                                      find-time ban-time
> +                                      ban-time-increment ban-time-factor
> +                                      ban-time-formula ban-time-multipliers
> +                                      ban-time-maxtime ban-time-rndtime
> +                                      ban-time-overalljails
> +                                      ignore-command ignore-self
> +                                      ignore-ip ignore-cache
> +                                      fltr
> +                                      log-time-zone log-encoding log-path
> +                                      action)
> +     (string-join
> +      (filter
> +       (lambda (s) (not (unspecified? s)))
> +       (list
> +        (fail2ban-section->string name)
> +        (unless (unspecified? enabled)
> +          (format #f "enabled = ~a"
> +                  (if enabled "true" "false")))
> +        (unless (unspecified? backend)
> +          (format #f "backend = ~a"
> +                  (fail2ban-backend->string backend)))
> +        (unless (unspecified? max-retry)
> +          (format #f "maxretry = ~d" max-retry))
> +        (unless (unspecified? max-matches)
> +          (format #f "maxmatches = ~d" max-matches))
> +        (unless (unspecified? find-time)
> +          (format #f "findtime = ~a" find-time))
> +        (unless (unspecified? ban-time)
> +          (format #f "bantime = ~a" ban-time))
> +        (unless (unspecified? ban-time-increment)
> +          (format #f "bantime.increment = ~a"
> +                  (if ban-time-increment "true" "false")))
> +        (unless (unspecified? ban-time-factor)
> +          (format #f "bantime.factor = ~a" ban-time-factor))
> +        (unless (unspecified? ban-time-formula)
> +          (format #f "bantime.formula = ~a" ban-time-formula))
> +        (unless (unspecified? ban-time-multipliers)
> +          (format #f "bantime.multipliers = ~a" ban-time-multipliers))
> +        (unless (unspecified? ban-time-maxtime)
> +          (format #f "bantime.maxtime = ~a" ban-time-maxtime))
> +        (unless (unspecified? ban-time-rndtime)
> +          (format #f "bantime.rndtime = ~a" ban-time-rndtime))
> +        (unless (unspecified? ban-time-overalljails)
> +          (format #f "bantime.overalljails = ~a"
> +                  (if ban-time-overalljails "true" "false")))
> +        (unless (unspecified? ignore-command)
> +          (format #f "ignorecommand = ~a" ignore-command))
> +        (unless (unspecified? ignore-self)
> +          (format #f "ignoreself = ~a"
> +                  (if ignore-self "true" "false")))
> +        (unless (null? ignore-ip)
> +          (format #f "ignoreip = ~a" (string-join ignore-ip " ")))
> +        (unless (unspecified? ignore-cache)
> +          (format #f "ignorecache = ~a"
> +                  (fail2ban-ignore-cache-configuration->string
> +                   ignore-cache)))
> +        (unless (unspecified? fltr)
> +          (format #f "filter = ~a"
> +                  (fail2ban-jail-filter-configuration->string fltr)))
> +        (unless (unspecified? log-time-zone)
> +          (format #f "logtimezone = ~a" log-time-zone))
> +        (unless (unspecified? log-encoding)
> +          (format #f "logencoding = ~a"
> +                  (fail2ban-log-encoding->string log-encoding)))
> +        (unless (unspecified? log-path)
> +          (format #f "logpath = ~a" log-path))
> +        (unless (null? action)
> +          (format #f "action = ~a"
> +                  (string-join
> +                   (map fail2ban-jail-action-configuration->string action)
> +                   "\n")))))
> +      "\n"))))
> +
> +(define-record-type* <fail2ban-configuration>
> +  fail2ban-configuration make-fail2ban-configuration
> +  fail2ban-configuration?
> +  (fail2ban              fail2ban-configuration-fail2ban
> +                         (default fail2ban))
> +  (run-directory         fail2ban-configuration-run-directory
> +                         (default "/var/run/fail2ban"))
> +  (jails                 fail2ban-configuration-jails
> +                         (default '()))
> +  (extra-jails           fail2ban-configuration-extra-jails
> +                         (default '()))
> +  (extra-content         fail2ban-configuration-extra-content
> +                         (default "")))
> +
> +(define (fail2ban-configuration->string config)
> +  (let* ((jails (fail2ban-configuration-jails config))
> +         (extra-jails (fail2ban-configuration-extra-jails config))
> +         (extra-content (fail2ban-configuration-extra-content config)))
> +    (string-append
> +     (string-join
> +      (map fail2ban-jail-configuration->string
> +           (append jails extra-jails))
> +      "\n")
> +     "\n" extra-content "\n")))
> +
> +(define (make-fail2ban-configuration-package config)
> +  (let* ((fail2ban (fail2ban-configuration-fail2ban config))
> +         (jail-local
> +          (plain-file "jail.local"
> +                      (fail2ban-configuration->string config))))
> +    (computed-file
> +     "fail2ban-configuration"
> +     (with-imported-modules '((guix build utils))
> +       #~(begin
> +           (use-modules (guix build utils))
> +           (let* ((out (ungexp output)))
> +             (mkdir-p (string-append out "/etc/fail2ban"))
> +             (copy-recursively
> +              (string-append #$fail2ban "/etc/fail2ban")
> +              (string-append out "/etc/fail2ban"))
> +             (symlink
> +              #$jail-local
> +              (string-append out "/etc/fail2ban/jail.local"))))))))
> +
> +(define (fail2ban-shepherd-service config)
> +  (match-record config <fail2ban-configuration>
> +    (fail2ban run-directory)
> +    (let* ((fail2ban-server (file-append fail2ban "/bin/fail2ban-server"))
> +           (pid-file (in-vicinity run-directory "fail2ban.pid"))
> +           (socket-file (in-vicinity run-directory "fail2ban.sock"))
> +           (config-dir (make-fail2ban-configuration-package config))
> +           (config-dir (file-append config-dir "/etc/fail2ban"))
> +           (fail2ban-action
> +            (lambda args
> +              #~(lambda _
> +                  (invoke #$fail2ban-server
> +                          "-c" #$config-dir
> +                          "-p" #$pid-file
> +                          "-s" #$socket-file
> +                          "-b"
> +                          #$@args)))))
> +
> +      ;; TODO: Add 'reload' action.
> +      (list (shepherd-service
> +             (provision '(fail2ban))
> +             (documentation "Run the fail2ban daemon.")
> +             (requirement '(user-processes))
> +             (modules `((ice-9 match)
> +                        ,@%default-modules))
> +             (start (fail2ban-action "start"))
> +             (stop (fail2ban-action "stop")))))))
> +
> +(define fail2ban-service-type
> +  (service-type (name 'fail2ban)
> +                (extensions
> +                 (list (service-extension shepherd-root-service-type
> +                                          fail2ban-shepherd-service)))
> +                (compose concatenate)
> +                (extend (lambda (config jails)
> +                          (fail2ban-configuration
> +                           (inherit config)
> +                           (jails
> +                            (append
> +                             (fail2ban-configuration-jails config)
> +                             jails)))))
> +                (default-value (fail2ban-configuration))
> +                (description "Run the fail2ban server.")))
> +
> +(define (fail2ban-jail-service svc-type jail)
> +  (service-type
> +   (inherit svc-type)
> +   (extensions
> +    (append
> +     (service-type-extensions svc-type)
> +     (list (service-extension fail2ban-service-type
> +                              (lambda _ (list jail))))))))

Overall this looks very good to me.  A system test attempting to brute
force an SSH port and being blocked would be neat, to provide some
insurance that it actually works, and that it continues working in the
future.  An OS template with an SSH service, the fail2ban service, and
then localhost attempting a few erroneous login attempts to self, and a
check to see if localhost was blocked in iptables, or something similar.
Do you think it'd be feasible?

Thanks,

Maxim