1{ config, lib, pkgs, ... }:
2
3with lib;
4
5let
6 cfg = config.services.geoip-updater;
7
8 dbBaseUrl = "https://geolite.maxmind.com/download/geoip/database";
9
10 randomizedTimerDelaySec = "3600";
11
12 # Use writeScriptBin instead of writeScript, so that argv[0] (logged to the
13 # journal) doesn't include the long nix store path hash. (Prefixing the
14 # ExecStart= command with '@' doesn't work because we start a shell (new
15 # process) that creates a new argv[0].)
16 geoip-updater = pkgs.writeScriptBin "geoip-updater" ''
17 #!${pkgs.stdenv.shell}
18 skipExisting=0
19 debug()
20 {
21 echo "<7>$@"
22 }
23 info()
24 {
25 echo "<6>$@"
26 }
27 error()
28 {
29 echo "<3>$@"
30 }
31 die()
32 {
33 error "$@"
34 exit 1
35 }
36 waitNetworkOnline()
37 {
38 ret=1
39 for i in $(seq 6); do
40 curl_out=$("${pkgs.curl.bin}/bin/curl" \
41 --silent --fail --show-error --max-time 60 "${dbBaseUrl}" 2>&1)
42 if [ $? -eq 0 ]; then
43 debug "Server is reachable (try $i)"
44 ret=0
45 break
46 else
47 debug "Server is unreachable (try $i): $curl_out"
48 sleep 10
49 fi
50 done
51 return $ret
52 }
53 dbFnameTmp()
54 {
55 dburl=$1
56 echo "${cfg.databaseDir}/.$(basename "$dburl")"
57 }
58 dbFnameTmpDecompressed()
59 {
60 dburl=$1
61 echo "${cfg.databaseDir}/.$(basename "$dburl")" | sed 's/\.\(gz\|xz\)$//'
62 }
63 dbFname()
64 {
65 dburl=$1
66 echo "${cfg.databaseDir}/$(basename "$dburl")" | sed 's/\.\(gz\|xz\)$//'
67 }
68 downloadDb()
69 {
70 dburl=$1
71 curl_out=$("${pkgs.curl.bin}/bin/curl" \
72 --silent --fail --show-error --max-time 900 -L -o "$(dbFnameTmp "$dburl")" "$dburl" 2>&1)
73 if [ $? -ne 0 ]; then
74 error "Failed to download $dburl: $curl_out"
75 return 1
76 fi
77 }
78 decompressDb()
79 {
80 fn=$(dbFnameTmp "$1")
81 ret=0
82 case "$fn" in
83 *.gz)
84 cmd_out=$("${pkgs.gzip}/bin/gzip" --decompress --force "$fn" 2>&1)
85 ;;
86 *.xz)
87 cmd_out=$("${pkgs.xz.bin}/bin/xz" --decompress --force "$fn" 2>&1)
88 ;;
89 *)
90 cmd_out=$(echo "File \"$fn\" is neither a .gz nor .xz file")
91 false
92 ;;
93 esac
94 if [ $? -ne 0 ]; then
95 error "$cmd_out"
96 ret=1
97 fi
98 }
99 atomicRename()
100 {
101 dburl=$1
102 mv "$(dbFnameTmpDecompressed "$dburl")" "$(dbFname "$dburl")"
103 }
104 removeIfNotInConfig()
105 {
106 # Arg 1 is the full path of an installed DB.
107 # If the corresponding database is not specified in the NixOS config we
108 # remove it.
109 db=$1
110 for cdb in ${lib.concatStringsSep " " cfg.databases}; do
111 confDb=$(echo "$cdb" | sed 's/\.\(gz\|xz\)$//')
112 if [ "$(basename "$db")" = "$(basename "$confDb")" ]; then
113 return 0
114 fi
115 done
116 rm "$db"
117 if [ $? -eq 0 ]; then
118 debug "Removed $(basename "$db") (not listed in services.geoip-updater.databases)"
119 else
120 error "Failed to remove $db"
121 fi
122 }
123 removeUnspecifiedDbs()
124 {
125 for f in "${cfg.databaseDir}/"*; do
126 test -f "$f" || continue
127 case "$f" in
128 *.dat|*.mmdb|*.csv)
129 removeIfNotInConfig "$f"
130 ;;
131 *)
132 debug "Not removing \"$f\" (unknown file extension)"
133 ;;
134 esac
135 done
136 }
137 downloadAndInstall()
138 {
139 dburl=$1
140 if [ "$skipExisting" -eq 1 -a -f "$(dbFname "$dburl")" ]; then
141 debug "Skipping existing file: $(dbFname "$dburl")"
142 return 0
143 fi
144 downloadDb "$dburl" || return 1
145 decompressDb "$dburl" || return 1
146 atomicRename "$dburl" || return 1
147 info "Updated $(basename "$(dbFname "$dburl")")"
148 }
149 for arg in "$@"; do
150 case "$arg" in
151 --skip-existing)
152 skipExisting=1
153 info "Option --skip-existing is set: not updating existing databases"
154 ;;
155 *)
156 error "Unknown argument: $arg";;
157 esac
158 done
159 waitNetworkOnline || die "Network is down (${dbBaseUrl} is unreachable)"
160 test -d "${cfg.databaseDir}" || die "Database directory (${cfg.databaseDir}) doesn't exist"
161 debug "Starting update of GeoIP databases in ${cfg.databaseDir}"
162 all_ret=0
163 for db in ${lib.concatStringsSep " \\\n " cfg.databases}; do
164 downloadAndInstall "${dbBaseUrl}/$db" || all_ret=1
165 done
166 removeUnspecifiedDbs || all_ret=1
167 if [ $all_ret -eq 0 ]; then
168 info "Completed GeoIP database update in ${cfg.databaseDir}"
169 else
170 error "Completed GeoIP database update in ${cfg.databaseDir}, with error(s)"
171 fi
172 # Hack to work around systemd journal race:
173 # https://github.com/systemd/systemd/issues/2913
174 sleep 2
175 exit $all_ret
176 '';
177
178in
179
180{
181 options = {
182 services.geoip-updater = {
183 enable = mkOption {
184 default = false;
185 type = types.bool;
186 description = ''
187 Whether to enable periodic downloading of GeoIP databases from
188 maxmind.com. You might want to enable this if you, for instance, use
189 ntopng or Wireshark.
190 '';
191 };
192
193 interval = mkOption {
194 type = types.str;
195 default = "weekly";
196 description = ''
197 Update the GeoIP databases at this time / interval.
198 The format is described in
199 <citerefentry><refentrytitle>systemd.time</refentrytitle>
200 <manvolnum>7</manvolnum></citerefentry>.
201 To prevent load spikes on maxmind.com, the timer interval is
202 randomized by an additional delay of ${randomizedTimerDelaySec}
203 seconds. Setting a shorter interval than this is not recommended.
204 '';
205 };
206
207 databaseDir = mkOption {
208 type = types.path;
209 default = "/var/lib/geoip-databases";
210 description = ''
211 Directory that will contain GeoIP databases.
212 '';
213 };
214
215 databases = mkOption {
216 type = types.listOf types.str;
217 default = [
218 "GeoLiteCountry/GeoIP.dat.gz"
219 "GeoIPv6.dat.gz"
220 "GeoLiteCity.dat.xz"
221 "GeoLiteCityv6-beta/GeoLiteCityv6.dat.gz"
222 "asnum/GeoIPASNum.dat.gz"
223 "asnum/GeoIPASNumv6.dat.gz"
224 "GeoLite2-Country.mmdb.gz"
225 "GeoLite2-City.mmdb.gz"
226 ];
227 description = ''
228 Which GeoIP databases to update. The full URL is ${dbBaseUrl}/ +
229 <literal>the_database</literal>.
230 '';
231 };
232
233 };
234
235 };
236
237 config = mkIf cfg.enable {
238
239 assertions = [
240 { assertion = (builtins.filter
241 (x: builtins.match ".*\.(gz|xz)$" x == null) cfg.databases) == [];
242 message = ''
243 services.geoip-updater.databases supports only .gz and .xz databases.
244
245 Current value:
246 ${toString cfg.databases}
247
248 Offending element(s):
249 ${toString (builtins.filter (x: builtins.match ".*\.(gz|xz)$" x == null) cfg.databases)};
250 '';
251 }
252 ];
253
254 users.extraUsers.geoip = {
255 group = "root";
256 description = "GeoIP database updater";
257 uid = config.ids.uids.geoip;
258 };
259
260 systemd.timers.geoip-updater =
261 { description = "GeoIP Updater Timer";
262 partOf = [ "geoip-updater.service" ];
263 wantedBy = [ "timers.target" ];
264 timerConfig.OnCalendar = cfg.interval;
265 timerConfig.Persistent = "true";
266 timerConfig.RandomizedDelaySec = randomizedTimerDelaySec;
267 };
268
269 systemd.services.geoip-updater = {
270 description = "GeoIP Updater";
271 after = [ "network-online.target" "nss-lookup.target" ];
272 wants = [ "network-online.target" ];
273 preStart = ''
274 mkdir -p "${cfg.databaseDir}"
275 chmod 755 "${cfg.databaseDir}"
276 chown geoip:root "${cfg.databaseDir}"
277 '';
278 serviceConfig = {
279 ExecStart = "${geoip-updater}/bin/geoip-updater";
280 User = "geoip";
281 PermissionsStartOnly = true;
282 };
283 };
284
285 systemd.services.geoip-updater-setup = {
286 description = "GeoIP Updater Setup";
287 after = [ "network-online.target" "nss-lookup.target" ];
288 wants = [ "network-online.target" ];
289 wantedBy = [ "multi-user.target" ];
290 conflicts = [ "geoip-updater.service" ];
291 preStart = ''
292 mkdir -p "${cfg.databaseDir}"
293 chmod 755 "${cfg.databaseDir}"
294 chown geoip:root "${cfg.databaseDir}"
295 '';
296 serviceConfig = {
297 ExecStart = "${geoip-updater}/bin/geoip-updater --skip-existing";
298 User = "geoip";
299 PermissionsStartOnly = true;
300 # So it won't be (needlessly) restarted:
301 RemainAfterExit = true;
302 };
303 };
304
305 };
306}