This repository has no description
12 kB
391 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
33 log() {
34 echo "[$(date -Iseconds)] $*"
35 }
36
37 mark_dirty() {
38 touch "$DIRTY_FLAG"
39 }
40
41 # strip $DOWNLOAD_ROOT/ prefix to get the relative path used inside MFS
42 relpath() {
43 echo "''${1#$DOWNLOAD_ROOT/}"
44 }
45
46 mfs_add() {
47 local cid="$1" path="$2"
48 local rel dir
49 rel=$(relpath "$path")
50 dir=$(${pkgs.coreutils}/bin/dirname "$rel")
51 # kubo's `files rm/cp/mkdir` write detailed errors to stdout, not
52 # stderr, so both FDs need redirection to keep backfill quiet.
53 $IPFS files mkdir -p "$FLIX_INDEX/$dir" >/dev/null 2>&1 || true
54 $IPFS files rm "$FLIX_INDEX/$rel" >/dev/null 2>&1 || true
55 $IPFS files cp "/ipfs/$cid" "$FLIX_INDEX/$rel" >/dev/null 2>&1 || true
56 mark_dirty
57 }
58
59 mfs_rm() {
60 local path="$1"
61 local rel
62 rel=$(relpath "$path")
63 $IPFS files rm "$FLIX_INDEX/$rel" >/dev/null 2>&1 || true
64 mark_dirty
65 }
66
67 # ensure IPNS key + MFS root exist (idempotent)
68 if ! $IPFS key list | ${pkgs.gnugrep}/bin/grep -qx flix-index; then
69 log "Generating flix-index IPNS key"
70 $IPFS key gen --type=ed25519 flix-index > "$IPNS_NAME_FILE" || true
71 fi
72 if [ -f "$IPNS_NAME_FILE" ]; then
73 log "flix-index IPNS name: $(cat "$IPNS_NAME_FILE")"
74 fi
75 $IPFS files mkdir -p "$FLIX_INDEX" >/dev/null 2>&1 || true
76
77 add_file() {
78 local path="$1"
79 [ -f "$path" ] || return 0
80 case "$(basename "$path")" in
81 .* | *.part | *.tmp | *.synced.*) return 0 ;;
82 esac
83
84 log "Adding $path"
85 cid=$($IPFS add --nocopy --pin --quieter "$path" 2>/dev/null) || {
86 log "Failed to add $path"
87 return 0
88 }
89 log "Pinned $path -> $cid"
90 ${pkgs.gnused}/bin/sed -i "\|^[^ ]* $path$|d" "$CID_MAP"
91 echo "$cid $path" >> "$CID_MAP"
92 mfs_add "$cid" "$path"
93 }
94
95 remove_file() {
96 local path="$1"
97 cid=$(${pkgs.gawk}/bin/awk -v p="$path" '$0 ~ p {print $1; exit}' "$CID_MAP")
98 if [ -n "$cid" ]; then
99 log "Unpinning $cid ($path)"
100 # remove from MFS first so the implicit MFS pin doesn't keep it alive
101 mfs_rm "$path"
102 $IPFS pin rm "$cid" 2>/dev/null || true
103 ${pkgs.gnused}/bin/sed -i "\|^[^ ]* $path$|d" "$CID_MAP"
104 else
105 log "No CID found for removed file: $path"
106 fi
107 }
108
109 # initial sync
110 log "Starting initial sync..."
111 for dir in ${lib.escapeShellArgs watchDirs}; do
112 if [ -d "$dir" ]; then
113 ${pkgs.findutils}/bin/find "$dir" -type f | while read -r f; do
114 if ! ${pkgs.gnugrep}/bin/grep -q " $f$" "$CID_MAP" 2>/dev/null; then
115 add_file "$f"
116 fi
117 done
118 fi
119 done
120 log "Initial sync complete"
121
122 # clean stale entries
123 log "Cleaning stale entries..."
124 while IFS=' ' read -r cid path; do
125 if [ ! -f "$path" ]; then
126 log "Stale entry: $path (CID: $cid)"
127 mfs_rm "$path"
128 $IPFS pin rm "$cid" 2>/dev/null || true
129 ${pkgs.gnused}/bin/sed -i "\|^[^ ]* $path$|d" "$CID_MAP"
130 fi
131 done < "$CID_MAP"
132 log "Cleanup complete"
133
134 # backfill MFS index from cid-map.txt once (sentinel-guarded so we run
135 # exactly once per machine, independently of whether the initial sync
136 # happened to create any MFS entries incrementally).
137 BACKFILL_SENTINEL="/var/lib/ipfs/flix-index.backfilled"
138 if [ ! -f "$BACKFILL_SENTINEL" ]; then
139 log "Backfilling MFS index from cid-map.txt..."
140 n=0
141 while IFS=' ' read -r cid path; do
142 [ -f "$path" ] || continue
143 # skip entries already present (idempotent re-runs)
144 rel=$(relpath "$path")
145 if $IPFS files stat "$FLIX_INDEX/$rel" >/dev/null 2>&1; then
146 continue
147 fi
148 mfs_add "$cid" "$path"
149 n=$((n + 1))
150 done < "$CID_MAP"
151 touch "$BACKFILL_SENTINEL"
152 log "Backfill complete ($n entries added)"
153 fi
154
155 # add all files in a newly appeared directory
156 scan_new_dir() {
157 local dir="$1"
158 log "Scanning new directory: $dir"
159 ${pkgs.findutils}/bin/find "$dir" -type f | while read -r f; do
160 add_file "$f"
161 done
162 }
163
164 # watch for changes
165 log "Watching directories: ${lib.concatStringsSep ", " watchDirs}"
166 $INOTIFYWAIT -m -r --format $'%w\t%e\t%f' \
167 -e close_write \
168 -e moved_to \
169 -e moved_from \
170 -e delete \
171 -e create \
172 ${lib.escapeShellArgs watchDirs} |
173 while IFS=$'\t' read -r dir event file; do
174 path="''${dir}''${file}"
175 case "$event" in
176 CREATE,ISDIR*|MOVED_TO,ISDIR*)
177 scan_new_dir "$path"
178 ;;
179 CLOSE_WRITE*|MOVED_TO*)
180 add_file "$path"
181 ;;
182 DELETE*|MOVED_FROM*)
183 remove_file "$path"
184 ;;
185 esac
186 done
187 '';
188
189 publishScript = pkgs.writers.writeBash "flix-index-publish" ''
190 set -efu
191
192 IPFS="${pkgs.kubo}/bin/ipfs"
193 CID_MAP="${cidMapFile}"
194 DOWNLOAD_ROOT="${downloadRoot}"
195 FLIX_INDEX="${flixIndexMfs}"
196 DIRTY_FLAG="${dirtyFlag}"
197
198 [ -f "$DIRTY_FLAG" ] || exit 0
199
200 # regenerate index.tsv from cid-map.txt
201 tmp=$(${pkgs.coreutils}/bin/mktemp)
202 printf 'cid\tpath\tsize\n' > "$tmp"
203 while IFS=' ' read -r cid path; do
204 [ -f "$path" ] || continue
205 rel="''${path#$DOWNLOAD_ROOT/}"
206 size=$(${pkgs.coreutils}/bin/stat -c %s "$path" 2>/dev/null || echo 0)
207 printf '%s\t%s\t%s\n' "$cid" "$rel" "$size" >> "$tmp"
208 done < "$CID_MAP"
209
210 $IPFS files rm "$FLIX_INDEX/index.tsv" >/dev/null 2>&1 || true
211 $IPFS files write --create --truncate "$FLIX_INDEX/index.tsv" < "$tmp"
212 ${pkgs.coreutils}/bin/rm -f "$tmp"
213
214 root=$($IPFS files stat --hash "$FLIX_INDEX")
215 echo "Publishing $root to IPNS (flix-index)"
216 $IPFS name publish --key=flix-index --lifetime=48h --ttl=1m "/ipfs/$root"
217 # Announce the MFS root to the DHT so public gateways can find us.
218 # MFS directory blocks are only kept alive by an implicit pin that
219 # Provide.Strategy="pinned" ignores, so we provide them explicitly here.
220 echo "Announcing $root to DHT"
221 $IPFS routing provide "$root" >/dev/null 2>&1 || true
222 ${pkgs.coreutils}/bin/rm -f "$DIRTY_FLAG"
223 '';
224
225 # Periodic reconciliation: walks cid-map.txt and repairs stale state for
226 # files that no longer exist on disk. Protects against inotify event
227 # drops (e.g. queue overflow during bulk renames/deletes) and any other
228 # path that leaves cid-map/MFS out of sync with the filesystem.
229 reconcileScript = pkgs.writers.writeBash "flix-index-reconcile" ''
230 set -efu
231
232 IPFS="${pkgs.kubo}/bin/ipfs"
233 CID_MAP="${cidMapFile}"
234 DOWNLOAD_ROOT="${downloadRoot}"
235 FLIX_INDEX="${flixIndexMfs}"
236 DIRTY_FLAG="${dirtyFlag}"
237
238 [ -f "$CID_MAP" ] || exit 0
239
240 # Rebuild cid-map in one pass, collecting stale entries to clean up.
241 # Avoids sed regex issues with special chars like [ ] in paths.
242 # Small race with pin-watcher's concurrent appends — any line the
243 # watcher writes during this loop may be lost, but will be re-added
244 # on the next inotify event or watcher restart.
245 tmp=$(${pkgs.coreutils}/bin/mktemp)
246 removed=0
247 while IFS=' ' read -r cid path; do
248 [ -n "$cid" ] || continue
249 if [ -e "$path" ]; then
250 echo "$cid $path" >> "$tmp"
251 else
252 rel="''${path#$DOWNLOAD_ROOT/}"
253 $IPFS files rm "$FLIX_INDEX/$rel" >/dev/null 2>&1 || true
254 $IPFS pin rm "$cid" >/dev/null 2>&1 || true
255 removed=$((removed + 1))
256 fi
257 done < "$CID_MAP"
258
259 if [ "$removed" -gt 0 ]; then
260 ${pkgs.coreutils}/bin/mv "$tmp" "$CID_MAP"
261 echo "reconciled: removed $removed stale entries"
262 touch "$DIRTY_FLAG"
263 else
264 ${pkgs.coreutils}/bin/rm -f "$tmp"
265 fi
266 '';
267in
268{
269 # symlink /var/download into IPFS root so --nocopy works
270 systemd.tmpfiles.rules = [
271 "L+ /var/lib/ipfs/download - - - - /var/download"
272 ];
273
274 services.kubo = {
275 enable = true;
276 settings = {
277 # kubo 0.40 deprecated Reprovider in favor of Provide; the nixpkgs
278 # module still injects a default Reprovider block, so null it out
279 # explicitly or kubo refuses to start.
280 Reprovider = null;
281 Experimental.FilestoreEnabled = true;
282 Addresses = {
283 API = [
284 "/ip4/127.0.0.1/tcp/5001"
285 "/ip6/::1/tcp/5001"
286 ];
287 Gateway = [
288 "/ip4/0.0.0.0/tcp/8089"
289 "/ip6/::/tcp/8089"
290 ];
291 };
292 Datastore.StorageMax = "100GB";
293 # limit bandwidth: 5MB/s out, 5MB/s in
294 Swarm.ConnMgr = {
295 LowWater = 100;
296 HighWater = 400;
297 GracePeriod = "20s";
298 };
299 Swarm.Transports.Network.TCP = true;
300 Swarm.Transports.Network.QUIC = true;
301 Swarm.ResourceMgr = {
302 Enabled = true;
303 MaxMemory = "2GB";
304 };
305 # disable relay to reduce overhead
306 Swarm.RelayClient.Enabled = false;
307 Swarm.RelayService.Enabled = false;
308 # dhtclient: announces our provider records (so peers can find content
309 # we host by CID) but does not serve DHT routing queries for others.
310 # autoclient would skip provide announcements and make our content
311 # invisible to bitswap.
312 Routing.Type = "dhtclient";
313 Provide = {
314 Strategy = "pinned";
315 DHT.Interval = "12h";
316 };
317 };
318 };
319
320 boot.kernel.sysctl."fs.inotify.max_user_watches" = 1048576;
321
322 systemd.services.ipfs-pin-watcher = {
323 description = "Auto-pin files to IPFS using inotify";
324 after = [ "ipfs.service" ];
325 requires = [ "ipfs.service" ];
326 wantedBy = [ "multi-user.target" ];
327 serviceConfig = {
328 Environment = [ "IPFS_PATH=/var/lib/ipfs" ];
329 ExecStart = pinWatcherScript;
330 Restart = "always";
331 RestartSec = "10s";
332 User = config.services.kubo.user;
333 Group = config.services.kubo.group;
334 SupplementaryGroups = [ "radio_container" ];
335 };
336 };
337
338 systemd.services.flix-index-publish = {
339 description = "Publish flix MFS index to IPNS";
340 after = [ "ipfs.service" ];
341 requires = [ "ipfs.service" ];
342 serviceConfig = {
343 Type = "oneshot";
344 Environment = [ "IPFS_PATH=/var/lib/ipfs" ];
345 ExecStart = publishScript;
346 User = config.services.kubo.user;
347 Group = config.services.kubo.group;
348 };
349 };
350
351 systemd.timers.flix-index-publish = {
352 description = "Publish flix MFS index to IPNS periodically";
353 wantedBy = [ "timers.target" ];
354 timerConfig = {
355 OnBootSec = "5min";
356 OnUnitActiveSec = "2min";
357 Unit = "flix-index-publish.service";
358 };
359 };
360
361 systemd.services.flix-index-reconcile = {
362 description = "Reconcile flix MFS index / cid-map against filesystem";
363 after = [ "ipfs.service" ];
364 requires = [ "ipfs.service" ];
365 serviceConfig = {
366 Type = "oneshot";
367 Environment = [ "IPFS_PATH=/var/lib/ipfs" ];
368 ExecStart = reconcileScript;
369 User = config.services.kubo.user;
370 Group = config.services.kubo.group;
371 };
372 };
373
374 systemd.timers.flix-index-reconcile = {
375 description = "Reconcile flix MFS index hourly";
376 wantedBy = [ "timers.target" ];
377 timerConfig = {
378 OnBootSec = "15min";
379 OnUnitActiveSec = "1h";
380 Unit = "flix-index-reconcile.service";
381 };
382 };
383
384 networking.firewall.allowedTCPPorts = [
385 4001 # IPFS swarm
386 8089 # IPFS gateway
387 ];
388 networking.firewall.allowedUDPPorts = [
389 4001 # IPFS swarm (QUIC)
390 ];
391}