This repository has no description
15 kB
455 lines
1{
2 config,
3 pkgs,
4 lib,
5 ...
6}:
7let
8 watchDirs = [
9 "/var/lib/ipfs/download/movies"
10 "/var/lib/ipfs/download/shows"
11 "/var/lib/ipfs/download/games"
12 ];
13
14 cidMapFile = "/var/lib/ipfs/cid-map.txt";
15 downloadRoot = "/var/lib/ipfs/download";
16 flixIndexMfs = "/flix-index";
17 dirtyFlag = "/var/lib/ipfs/flix-index.dirty";
18 ipnsNameFile = "/var/lib/ipfs/flix-index.name";
19
20 pinWatcherScript = pkgs.writers.writeBash "ipfs-pin-watcher" ''
21 set -efu
22
23 IPFS="${pkgs.kubo}/bin/ipfs"
24 INOTIFYWAIT="${pkgs.inotify-tools}/bin/inotifywait"
25 CID_MAP="${cidMapFile}"
26 DOWNLOAD_ROOT="${downloadRoot}"
27 FLIX_INDEX="${flixIndexMfs}"
28 DIRTY_FLAG="${dirtyFlag}"
29 IPNS_NAME_FILE="${ipnsNameFile}"
30
31 touch "$CID_MAP"
32 ${pkgs.coreutils}/bin/chmod 0644 "$CID_MAP"
33
34 log() {
35 echo "[$(date -Iseconds)] $*"
36 }
37
38 mark_dirty() {
39 touch "$DIRTY_FLAG"
40 }
41
42 # strip $DOWNLOAD_ROOT/ prefix to get the relative path used inside MFS
43 relpath() {
44 echo "''${1#$DOWNLOAD_ROOT/}"
45 }
46
47 mfs_add() {
48 local cid="$1" path="$2"
49 local rel dir
50 rel=$(relpath "$path")
51 dir=$(${pkgs.coreutils}/bin/dirname "$rel")
52 # kubo's `files rm/cp/mkdir` write detailed errors to stdout, not
53 # stderr, so both FDs need redirection to keep backfill quiet.
54 $IPFS files mkdir -p "$FLIX_INDEX/$dir" >/dev/null 2>&1 || true
55 $IPFS files rm "$FLIX_INDEX/$rel" >/dev/null 2>&1 || true
56 $IPFS files cp "/ipfs/$cid" "$FLIX_INDEX/$rel" >/dev/null 2>&1 || true
57 mark_dirty
58 }
59
60 mfs_rm() {
61 local path="$1"
62 local rel
63 rel=$(relpath "$path")
64 $IPFS files rm "$FLIX_INDEX/$rel" >/dev/null 2>&1 || true
65 mark_dirty
66 }
67
68 # ensure IPNS key + MFS root exist (idempotent)
69 if ! $IPFS key list | ${pkgs.gnugrep}/bin/grep -qx flix-index; then
70 log "Generating flix-index IPNS key"
71 $IPFS key gen --type=ed25519 flix-index > "$IPNS_NAME_FILE" || true
72 fi
73 if [ -f "$IPNS_NAME_FILE" ]; then
74 log "flix-index IPNS name: $(cat "$IPNS_NAME_FILE")"
75 fi
76 $IPFS files mkdir -p "$FLIX_INDEX" >/dev/null 2>&1 || true
77
78 add_file() {
79 local path="$1"
80 [ -f "$path" ] || return 0
81 case "$(basename "$path")" in
82 .* | *.part | *.tmp | *.synced.* | *.aria2) return 0 ;;
83 esac
84
85 log "Adding $path"
86 cid=$($IPFS add --nocopy --pin --quieter "$path" 2>/dev/null) || {
87 log "Failed to add $path"
88 return 0
89 }
90 log "Pinned $path -> $cid"
91 ${pkgs.gnused}/bin/sed -i "\|^[^ ]* $path$|d" "$CID_MAP"
92 echo "$cid $path" >> "$CID_MAP"
93 mfs_add "$cid" "$path"
94 }
95
96 remove_file() {
97 local path="$1"
98 cid=$(${pkgs.gawk}/bin/awk -v p="$path" '$0 ~ p {print $1; exit}' "$CID_MAP")
99 if [ -n "$cid" ]; then
100 log "Unpinning $cid ($path)"
101 # remove from MFS first so the implicit MFS pin doesn't keep it alive
102 mfs_rm "$path"
103 $IPFS pin rm "$cid" 2>/dev/null || true
104 ${pkgs.gnused}/bin/sed -i "\|^[^ ]* $path$|d" "$CID_MAP"
105 else
106 log "No CID found for removed file: $path"
107 fi
108 }
109
110 # initial sync
111 log "Starting initial sync..."
112 for dir in ${lib.escapeShellArgs watchDirs}; do
113 if [ -d "$dir" ]; then
114 ${pkgs.findutils}/bin/find "$dir" -type f | while read -r f; do
115 if ! ${pkgs.gnugrep}/bin/grep -q " $f$" "$CID_MAP" 2>/dev/null; then
116 add_file "$f"
117 fi
118 done
119 fi
120 done
121 log "Initial sync complete"
122
123 # clean stale entries
124 log "Cleaning stale entries..."
125 while IFS=' ' read -r cid path; do
126 if [ ! -f "$path" ]; then
127 log "Stale entry: $path (CID: $cid)"
128 mfs_rm "$path"
129 $IPFS pin rm "$cid" 2>/dev/null || true
130 ${pkgs.gnused}/bin/sed -i "\|^[^ ]* $path$|d" "$CID_MAP"
131 fi
132 done < "$CID_MAP"
133 log "Cleanup complete"
134
135 # backfill MFS index from cid-map.txt once (sentinel-guarded so we run
136 # exactly once per machine, independently of whether the initial sync
137 # happened to create any MFS entries incrementally).
138 BACKFILL_SENTINEL="/var/lib/ipfs/flix-index.backfilled"
139 if [ ! -f "$BACKFILL_SENTINEL" ]; then
140 log "Backfilling MFS index from cid-map.txt..."
141 n=0
142 while IFS=' ' read -r cid path; do
143 [ -f "$path" ] || continue
144 # skip entries already present (idempotent re-runs)
145 rel=$(relpath "$path")
146 if $IPFS files stat "$FLIX_INDEX/$rel" >/dev/null 2>&1; then
147 continue
148 fi
149 mfs_add "$cid" "$path"
150 n=$((n + 1))
151 done < "$CID_MAP"
152 touch "$BACKFILL_SENTINEL"
153 log "Backfill complete ($n entries added)"
154 fi
155
156 # add all files in a newly appeared directory
157 scan_new_dir() {
158 local dir="$1"
159 log "Scanning new directory: $dir"
160 ${pkgs.findutils}/bin/find "$dir" -type f | while read -r f; do
161 add_file "$f"
162 done
163 }
164
165 # watch for changes
166 log "Watching directories: ${lib.concatStringsSep ", " watchDirs}"
167 $INOTIFYWAIT -m -r --format $'%w\t%e\t%f' \
168 -e close_write \
169 -e moved_to \
170 -e moved_from \
171 -e delete \
172 -e create \
173 ${lib.escapeShellArgs watchDirs} |
174 while IFS=$'\t' read -r dir event file; do
175 path="''${dir}''${file}"
176 case "$event" in
177 CREATE,ISDIR*|MOVED_TO,ISDIR*)
178 scan_new_dir "$path"
179 ;;
180 CLOSE_WRITE*|MOVED_TO*)
181 add_file "$path"
182 ;;
183 DELETE*|MOVED_FROM*)
184 remove_file "$path"
185 ;;
186 esac
187 done
188 '';
189
190 publishScript = pkgs.writers.writeBash "flix-index-publish" ''
191 set -efu
192
193 IPFS="${pkgs.kubo}/bin/ipfs"
194 CID_MAP="${cidMapFile}"
195 DOWNLOAD_ROOT="${downloadRoot}"
196 FLIX_INDEX="${flixIndexMfs}"
197 DIRTY_FLAG="${dirtyFlag}"
198
199 [ -f "$DIRTY_FLAG" ] || exit 0
200
201 # regenerate index.tsv from cid-map.txt
202 tmp=$(${pkgs.coreutils}/bin/mktemp)
203 printf 'cid\tpath\tsize\n' > "$tmp"
204 while IFS=' ' read -r cid path; do
205 [ -f "$path" ] || continue
206 rel="''${path#$DOWNLOAD_ROOT/}"
207 size=$(${pkgs.coreutils}/bin/stat -c %s "$path" 2>/dev/null || echo 0)
208 printf '%s\t%s\t%s\n' "$cid" "$rel" "$size" >> "$tmp"
209 done < "$CID_MAP"
210
211 $IPFS files rm "$FLIX_INDEX/index.tsv" >/dev/null 2>&1 || true
212 $IPFS files write --create --truncate "$FLIX_INDEX/index.tsv" < "$tmp"
213 ${pkgs.coreutils}/bin/rm -f "$tmp"
214
215 root=$($IPFS files stat --hash "$FLIX_INDEX")
216 echo "Publishing $root to IPNS (flix-index)"
217 $IPFS name publish --key=flix-index --lifetime=48h --ttl=1m "/ipfs/$root"
218 # Announce the MFS root to the DHT so public gateways can find us.
219 # MFS directory blocks are only kept alive by an implicit pin that
220 # Provide.Strategy="pinned" ignores, so we provide them explicitly here.
221 echo "Announcing $root to DHT"
222 $IPFS routing provide "$root" >/dev/null 2>&1 || true
223 ${pkgs.coreutils}/bin/rm -f "$DIRTY_FLAG"
224 '';
225
226 # Periodic reconciliation: walks cid-map.txt and repairs stale state for
227 # files that no longer exist on disk. Protects against inotify event
228 # drops (e.g. queue overflow during bulk renames/deletes) and any other
229 # path that leaves cid-map/MFS out of sync with the filesystem.
230 uploadServer = pkgs.writers.writePython3Bin "ipfs-upload-server" {
231 flakeIgnore = [ "E501" ];
232 } ./ipfs_upload.py;
233
234 reconcileScript = pkgs.writers.writeBash "flix-index-reconcile" ''
235 set -efu
236
237 IPFS="${pkgs.kubo}/bin/ipfs"
238 CID_MAP="${cidMapFile}"
239 DOWNLOAD_ROOT="${downloadRoot}"
240 FLIX_INDEX="${flixIndexMfs}"
241 DIRTY_FLAG="${dirtyFlag}"
242
243 [ -f "$CID_MAP" ] || exit 0
244
245 # Rebuild cid-map in one pass, collecting stale entries to clean up.
246 # Avoids sed regex issues with special chars like [ ] in paths.
247 # Small race with pin-watcher's concurrent appends — any line the
248 # watcher writes during this loop may be lost, but will be re-added
249 # on the next inotify event or watcher restart.
250 tmp=$(${pkgs.coreutils}/bin/mktemp)
251 removed=0
252 while IFS=' ' read -r cid path; do
253 [ -n "$cid" ] || continue
254 if [ -e "$path" ]; then
255 echo "$cid $path" >> "$tmp"
256 else
257 rel="''${path#$DOWNLOAD_ROOT/}"
258 $IPFS files rm "$FLIX_INDEX/$rel" >/dev/null 2>&1 || true
259 $IPFS pin rm "$cid" >/dev/null 2>&1 || true
260 removed=$((removed + 1))
261 fi
262 done < "$CID_MAP"
263
264 if [ "$removed" -gt 0 ]; then
265 ${pkgs.coreutils}/bin/chmod 0644 "$tmp"
266 ${pkgs.coreutils}/bin/mv "$tmp" "$CID_MAP"
267 echo "reconciled: removed $removed stale entries"
268 touch "$DIRTY_FLAG"
269 else
270 ${pkgs.coreutils}/bin/rm -f "$tmp"
271 fi
272 '';
273in
274{
275 # symlink /var/download into IPFS root so --nocopy works
276 systemd.tmpfiles.rules = [
277 "L+ /var/lib/ipfs/download - - - - /var/download"
278 # Staging area for ipfs-upload: same filesystem as the destination, but
279 # outside the pin-watcher's watch dirs (movies/shows/games) so inotify
280 # never fires on in-flight uploads.
281 "d /var/download/incoming 0755 ${config.services.kubo.user} ${config.services.kubo.group} -"
282 "d /var/download/incoming/uploader 0755 ${config.services.kubo.user} ${config.services.kubo.group} -"
283 # Let the ipfs-upload service write into /var/download/games. Owner
284 # = ipfs so the service has write; group = users + setgid so existing
285 # CLI users (lass) keep write via group and new uploads inherit
286 # group=users.
287 "z /var/download/games 2775 ${config.services.kubo.user} users -"
288 ];
289
290 services.kubo = {
291 enable = true;
292 settings = {
293 # kubo 0.40 deprecated Reprovider in favor of Provide; the nixpkgs
294 # module still injects a default Reprovider block, so null it out
295 # explicitly or kubo refuses to start.
296 Reprovider = null;
297 Experimental.FilestoreEnabled = true;
298 Addresses = {
299 API = [
300 "/ip4/127.0.0.1/tcp/5001"
301 "/ip6/::1/tcp/5001"
302 ];
303 Gateway = [
304 "/ip4/0.0.0.0/tcp/8089"
305 "/ip6/::/tcp/8089"
306 ];
307 };
308 Datastore.StorageMax = "100GB";
309 # limit bandwidth: 5MB/s out, 5MB/s in
310 Swarm.ConnMgr = {
311 LowWater = 100;
312 HighWater = 400;
313 GracePeriod = "20s";
314 };
315 Swarm.Transports.Network.TCP = true;
316 Swarm.Transports.Network.QUIC = true;
317 Swarm.ResourceMgr = {
318 Enabled = true;
319 MaxMemory = "2GB";
320 };
321 # disable relay to reduce overhead
322 Swarm.RelayClient.Enabled = false;
323 Swarm.RelayService.Enabled = false;
324 # autoclient: DHT client (no server queries) + IPNI/cid.contact HTTP
325 # announcements. Content is discoverable by both DHT peers and lassie
326 # (which uses IPNI for provider discovery).
327 Routing.Type = "autoclient";
328 Provide = {
329 Strategy = "pinned";
330 DHT.Interval = "12h";
331 };
332 };
333 };
334
335 boot.kernel.sysctl."fs.inotify.max_user_watches" = 1048576;
336
337 systemd.services.ipfs-pin-watcher = {
338 description = "Auto-pin files to IPFS using inotify";
339 after = [ "ipfs.service" ];
340 requires = [ "ipfs.service" ];
341 wantedBy = [ "multi-user.target" ];
342 serviceConfig = {
343 Environment = [ "IPFS_PATH=/var/lib/ipfs" ];
344 ExecStart = pinWatcherScript;
345 Restart = "always";
346 RestartSec = "10s";
347 User = config.services.kubo.user;
348 Group = config.services.kubo.group;
349 SupplementaryGroups = [ "radio_container" ];
350 };
351 };
352
353 systemd.services.ipfs-upload = {
354 description = "HTTP upload endpoint that writes files under /var/lib/ipfs/download and pins them";
355 after = [ "ipfs.service" ];
356 requires = [ "ipfs.service" ];
357 wantedBy = [ "multi-user.target" ];
358 serviceConfig = {
359 Environment = [
360 "IPFS_PATH=/var/lib/ipfs"
361 "IPFS_BIN=${pkgs.kubo}/bin/ipfs"
362 "DOWNLOAD_ROOT=/var/lib/ipfs/download"
363 "BIND=127.0.0.1"
364 "PORT=8090"
365 ];
366 ExecStart = "${uploadServer}/bin/ipfs-upload-server";
367 Restart = "always";
368 RestartSec = "10s";
369 User = config.services.kubo.user;
370 Group = config.services.kubo.group;
371 # /var/download is owned by ipfs:ipfs on neoprism, so no extra
372 # groups needed. Existing subdirs owned by other users (e.g. games
373 # → lass:users) are not writable by this service; uploads to those
374 # paths will get a 500 unless ownership is adjusted out-of-band.
375 };
376 };
377
378 systemd.services.flix-index-publish = {
379 description = "Publish flix MFS index to IPNS";
380 after = [ "ipfs.service" ];
381 requires = [ "ipfs.service" ];
382 serviceConfig = {
383 Type = "oneshot";
384 Environment = [ "IPFS_PATH=/var/lib/ipfs" ];
385 ExecStart = publishScript;
386 User = config.services.kubo.user;
387 Group = config.services.kubo.group;
388 };
389 };
390
391 systemd.timers.flix-index-publish = {
392 description = "Publish flix MFS index to IPNS periodically";
393 wantedBy = [ "timers.target" ];
394 timerConfig = {
395 OnBootSec = "5min";
396 OnUnitActiveSec = "2min";
397 Unit = "flix-index-publish.service";
398 };
399 };
400
401 systemd.services.flix-index-reconcile = {
402 description = "Reconcile flix MFS index / cid-map against filesystem";
403 after = [ "ipfs.service" ];
404 requires = [ "ipfs.service" ];
405 serviceConfig = {
406 Type = "oneshot";
407 Environment = [ "IPFS_PATH=/var/lib/ipfs" ];
408 ExecStart = reconcileScript;
409 User = config.services.kubo.user;
410 Group = config.services.kubo.group;
411 };
412 };
413
414 systemd.timers.flix-index-reconcile = {
415 description = "Reconcile flix MFS index hourly";
416 wantedBy = [ "timers.target" ];
417 timerConfig = {
418 OnBootSec = "15min";
419 OnUnitActiveSec = "1h";
420 Unit = "flix-index-reconcile.service";
421 };
422 };
423
424 networking.firewall.allowedTCPPorts = [
425 4001 # IPFS swarm
426 8089 # IPFS gateway
427 ];
428 networking.firewall.allowedUDPPorts = [
429 4001 # IPFS swarm (QUIC)
430 ];
431
432 # Shadow `ipfs` in PATH with a wrapper that rejects `ipfs add` without
433 # --nocopy. The kubo daemon and pin-watcher use absolute store paths, so
434 # they are unaffected. This blocks confused agents/users from creating
435 # blockstore-only duplicates of files that already live in
436 # /var/lib/ipfs/download.
437 environment.systemPackages = [
438 (lib.hiPrio (
439 pkgs.writeShellScriptBin "ipfs" ''
440 if [ "''${1-}" = "add" ]; then
441 for a in "$@"; do
442 case "$a" in
443 --nocopy|--nocopy=true) exec ${pkgs.kubo}/bin/ipfs "$@" ;;
444 esac
445 done
446 echo "refused: 'ipfs add' without --nocopy is forbidden on this host." >&2
447 echo " Use 'ipfs add --nocopy <path>' so the file is referenced via filestore" >&2
448 echo " instead of duplicated into the blockstore." >&2
449 exit 1
450 fi
451 exec ${pkgs.kubo}/bin/ipfs "$@"
452 ''
453 ))
454 ];
455}