aboutsummaryrefslogtreecommitdiff
path: root/tw/services/restic.scm
blob: 62764750840af2ad250ee6bdca47f4afca28ff9e (about) (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
(define-module (tw services restic)
  #:use-module (gnu)
  #:use-module (gnu home services mcron)
  #:use-module ((gnu packages admin)
                #:select (shadow))
  #:use-module (gnu packages backup)
  #:use-module (gnu services)
  #:use-module (gnu services configuration)
  #:use-module (gnu services mcron)
  #:use-module (gnu services shepherd)
  #:use-module (guix gexp)
  #:use-module (guix packages)
  #:use-module (srfi srfi-1)
  #:export (restic-server-service-type
            restic-server-configuration
            restic-cleanup-service-type
            restic-cleanup-repository
            home-restic-backup-service-type
            restic-backup-repository))

(define %restic-user "restic")
(define %restic-group "restic")


;; Restic REST server

(define-maybe/no-serialization integer)
(define-maybe/no-serialization string)

;; TODO: implement --tls, --tls-cert and --tls-key, maybe using certbot-service-type?
;; TODO: implement --log
(define-configuration/no-serialization restic-server-configuration
  (repository-path (string "/var/lib/restic") "The directory containing
restic's repositories and @code{.htpasswd} file, unless otherwise configured
using @code{htpasswd-file}.")
  (restic-server (package restic-rest-server) "The restic REST server package to use.")
  (bind-address (string ":8000") "The listen address (including port) to bind to.")
  (htpasswd-file (maybe-string %unset-value) "Location of @code{.htpasswd}
file (default: @code{REPOSITORY-PATH/.htpasswd}).  Use @code{htpasswd} from
the @code{httpd} package to create and/or update this file.")
  (auth? (boolean #t) "Whether to authenticate users at all (using .htpasswd).")
  (verify-upload? (boolean #t) "Whether to verify the integrity of uploaded
data.  @emph{Do not disable} unless the restic server is to be run on a very
low-power device.")
  (append-only? (boolean #f) "Whether to run the restic server in append-only mode.")
  (max-repository-size (maybe-integer %unset-value) "Maximum repository size
in bytes, if any.")
  (private-repos-only? (boolean #f) "Whether to let users only access their
private restic repos.")
  (prometheus? (boolean #f) "Whether to serve Prometheus metrics.")
  (prometheus-auth? (boolean #t) "Whether to require authentication as the
@code{metrics} user to access the Prometheus /metrics endpoint."))

(define (restic-server-arguments config)
  "Turn CONFIG into a list of arguments to the restic-rest-server executable."
  `("--path" ,(restic-server-configuration-repository-path config)
    "--listen" ,(restic-server-configuration-bind-address config)
    ,@(let ((htpasswd-file (restic-server-configuration-htpasswd-file config)))
        (if (string? htpasswd-file) `("--htpasswd-file" ,htpasswd-file) '()))
    ,@(if (restic-server-configuration-auth? config) '() '("--no-auth"))
    ,@(if (restic-server-configuration-verify-upload? config) '() '("--no-verify-upload"))
    ,@(if (restic-server-configuration-append-only? config) '("--append-only") '())
    ,@(let ((max-size (restic-server-configuration-max-repository-size config)))
        (if (integer? max-size) `("--max-size" ,max-size) '()))
    ,@(if (restic-server-configuration-private-repos-only? config) '("--private-repos") '())
    ,@(if (restic-server-configuration-prometheus? config) '("--prometheus") '())
    ,@(if (restic-server-configuration-prometheus-auth? config) '() '("--prometheus-no-auth"))))

(define (restic-server-service config)
  "Create a `shepherd-service' for the restic REST server from CONFIG."
  (list (shepherd-service
         (provision '(restic-server))
         (requirement '(networking))
         (documentation "Run the Restic REST server to serve backup repositories via HTTP.")
         (start #~(make-forkexec-constructor
                   (list #$(file-append (restic-server-configuration-restic-server config)
                                        "/bin/restic-rest-server")
                         #$@(restic-server-arguments config))
                   #:user #$%restic-user #:group #$%restic-group))
         (stop #~(make-kill-destructor)))))

(define (restic-server-accounts config)
  "Create user accounts and groups for the restic REST server defined in CONFIG."
  (list (user-account
         (name %restic-user)
         (group %restic-group)
         (comment "Restic server user")
         (system? #t)
         (home-directory (restic-server-configuration-repository-path config))
         (shell (file-append shadow "/sbin/nologin")))
        (user-group
         (name %restic-group)
         (system? #t))))

(define restic-server-service-type
  (service-type
   (name 'restic-server)
   (extensions
    (list (service-extension shepherd-root-service-type restic-server-service)
          (service-extension account-service-type restic-server-accounts)))
   (description
    "Restic REST server, running as a service user instead of root.")))


;; Restic cleanup cronjobs

(define-maybe list-of-strings)

(define-configuration/no-serialization restic-cleanup-repository
  (schedule gexp "An mcron schedule, specified as a gexp
(@pxref{G-Expressions}), to use for the cleanup job.  String, list or lambda
syntax is fine (@pxref{Syntax, mcron job specifications,, mcron,
GNU@tie{}mcron}).")
  (url string "The directory path (or URL) of the restic repository to clean up.")
  (password-file string "The file name containing the repository password.
Must be readable by the @code{%restic-user}.")
  (restic (package restic) "The restic package to use.")
  (snapshot-paths (maybe-list-of-strings %unset-value) "Only consider
snapshots which include these paths.")
  (prune? (boolean #t) "Immediately prune the repo after deleting snapshots.")
  (keep-last (maybe-integer %unset-value) "Keep the last N snapshots.")
  (keep-hourly (maybe-integer %unset-value) "Keep the last N hourly snapshots.")
  (keep-daily (maybe-integer %unset-value) "Keep the last N daily snapshots.")
  (keep-weekly (maybe-integer %unset-value) "Keep the last N weekly snapshots.")
  (keep-monthly (maybe-integer %unset-value) "Keep the last N monthly snapshots.")
  (keep-yearly (maybe-integer %unset-value) "Keep the last N yearly snapshots.")
  (keep-within (maybe-string %unset-value) "Keep snapshots newer than the
given duration relative to the latest snapshot."))

(define (restic-cleanup-cronjobs repositories)
  (define (arg-with-value arg value)
    "Produce a list for the given command-line argument with optional value.
The result is inteded to be substituted into a `gexp' using `#$@', e.g. into
an `execl' call.  Numbers are converted to strings as if by `display'.  Lists
are turned into multiple arguments.  For booleans, ARG is returned if VALUE is
true."
    (define (to-string thing)
      (format #f "~a" thing))
    (cond
     ((eq? %unset-value value)
      '())
     ((boolean? value)
      (if value (list arg) '()))
     ((list? value)
      (append-map (lambda (item)
                    (list arg (to-string item)))
                  value))
     (else
      (list arg (to-string value)))))

  (define (cronjob repo)
    #~(job #$(restic-cleanup-repository-schedule repo)
           #$(program-file
              ;; Make cron commands for different repos easier to distinguish.
              (format #f "restic-cleanup-~a-command"
                      (string-trim-right (basename (restic-cleanup-repository-url repo)) #\/))
              #~(begin
                  ;; `setgid' first, while we're still root.
                  (setgid (group:gid (getgr #$%restic-group)))
                  (setuid (passwd:uid (getpw #$%restic-user)))
                  (setenv "RESTIC_REPOSITORY" '#$(restic-cleanup-repository-url repo))
                  (setenv "RESTIC_PASSWORD_FILE" '#$(restic-cleanup-repository-password-file repo))
                  (execl #$(file-append (restic-cleanup-repository-restic repo) "/bin/restic")
                         "restic" "forget" "--no-cache"
                         #$@(arg-with-value "--prune" (restic-cleanup-repository-prune? repo))
                         #$@(arg-with-value "--path" (restic-cleanup-repository-snapshot-paths repo))
                         #$@(arg-with-value "--keep-within" (restic-cleanup-repository-keep-within repo))
                         #$@(arg-with-value "--keep-last" (restic-cleanup-repository-keep-last repo))
                         #$@(arg-with-value "--keep-hourly" (restic-cleanup-repository-keep-hourly repo))
                         #$@(arg-with-value "--keep-daily" (restic-cleanup-repository-keep-daily repo))
                         #$@(arg-with-value "--keep-weekly" (restic-cleanup-repository-keep-weekly repo))
                         #$@(arg-with-value "--keep-monthly" (restic-cleanup-repository-keep-monthly repo))
                         #$@(arg-with-value "--keep-yearly" (restic-cleanup-repository-keep-yearly repo)))))))

  (map cronjob repositories))

(define restic-cleanup-service-type
  (service-type
   (name 'restic-cleanup)
   (extensions
    (list (service-extension mcron-service-type restic-cleanup-cronjobs)))
   (compose concatenate)
   (extend append)
   (default-value '())
   (description "Clean up old restic snapshots on a schedule.")))


;; Restic scheduled backup jobs

(define (nonempty-list-of-strings? thing)
  (and (pair? thing) (list-of-strings? thing)))

(define-configuration/no-serialization restic-backup-repository
  (schedule gexp "An mcron schedule, specified as a gexp
(@pxref{G-Expressions}), to use for the backup job.  String, list or lambda
syntax is fine (@pxref{Syntax, mcron job specifications,, mcron,
GNU@tie{}mcron}).")
  (paths nonempty-list-of-strings "List of paths to back up.  At least one
must be given.  Leading @code{~/} are replaced with @code{$HOME}.")
  (url-command string "Run this command (inside @code{sh -c}) to obtain the
directory or URL of the repo to back up to.  This allows you to substitute
passwords in @code{rest:} URLs.")
  (password-command string "Run this command to obtain the repository password.")
  (tags (list-of-strings '()) "Optional tags to add to the snapshot.")
  (restic (package restic) "The restic package to use."))

(define (restic-backup-cronjobs repositories)
  (define (cronjob repo)
    #~(job #$(restic-backup-repository-schedule repo)
           #$(program-file
              "restic-backup-command"
              #~(begin
                  (use-modules (ice-9 popen)
                               (ice-9 textual-ports)
                               (srfi srfi-1))
                  (define (replace-home path)
                    (if (string-prefix? "~/" path)
                        (string-replace path (getenv "HOME") 0 1)
                        path))
                  (setenv "RESTIC_PASSWORD_COMMAND"
                          '#$(restic-backup-repository-password-command repo))
                  (let ((pipe (open-pipe '#$(restic-backup-repository-url-command repo) OPEN_READ)))
                    (setenv "RESTIC_REPOSITORY" (string-trim-right (get-string-all pipe) #\newline))
                    (close-pipe pipe))
                  (apply execl #$(file-append (restic-backup-repository-restic repo) "/bin/restic")
                         "restic" "backup" "--cleanup-cache"
                         #$@(append-map (lambda (tag) (list "--tag" tag))
                                        (restic-backup-repository-tags repo))
                         (map replace-home '#$(restic-backup-repository-paths repo)))))))
  (map cronjob repositories))

(define home-restic-backup-service-type
  (service-type
   (name 'restic-backup)
   (extensions
    (list (service-extension home-mcron-service-type restic-backup-cronjobs)))
   (compose concatenate)
   (extend append)
   (default-value '())
   (description "Back up local directories on a schedule.")))