This repository has no description
12 kB
351 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
16 pinWatcherScript = pkgs.writers.writeBash "ipfs-pin-watcher" ''
17 set -efu
18
19 IPFS="${pkgs.kubo}/bin/ipfs"
20 INOTIFYWAIT="${pkgs.inotify-tools}/bin/inotifywait"
21 CID_MAP="${cidMapFile}"
22
23 touch "$CID_MAP"
24 ${pkgs.coreutils}/bin/chmod 0644 "$CID_MAP"
25
26 log() {
27 echo "[$(date -Iseconds)] $*"
28 }
29
30 add_file() {
31 local path="$1"
32 [ -f "$path" ] || return 0
33 case "$(basename "$path")" in
34 .* | *.part | *.tmp | *.synced.* | *.aria2) return 0 ;;
35 esac
36
37 log "Adding $path"
38 cid=$($IPFS add --nocopy --pin --quieter "$path" 2>/dev/null) || {
39 log "Failed to add $path"
40 return 0
41 }
42 log "Pinned $path -> $cid"
43 ${pkgs.gnused}/bin/sed -i "\|^[^ ]* $path$|d" "$CID_MAP"
44 echo "$cid $path" >> "$CID_MAP"
45 }
46
47 remove_file() {
48 local path="$1"
49 cid=$(${pkgs.gawk}/bin/awk -v p="$path" '$0 ~ p {print $1; exit}' "$CID_MAP")
50 if [ -n "$cid" ]; then
51 log "Unpinning $cid ($path)"
52 $IPFS pin rm "$cid" 2>/dev/null || true
53 ${pkgs.gnused}/bin/sed -i "\|^[^ ]* $path$|d" "$CID_MAP"
54 else
55 log "No CID found for removed file: $path"
56 fi
57 }
58
59 # initial sync: pin any file in the watch dirs not already in cid-map
60 log "Starting initial sync..."
61 for dir in ${lib.escapeShellArgs watchDirs}; do
62 if [ -d "$dir" ]; then
63 ${pkgs.findutils}/bin/find "$dir" -type f | while read -r f; do
64 if ! ${pkgs.gnugrep}/bin/grep -q " $f$" "$CID_MAP" 2>/dev/null; then
65 add_file "$f"
66 fi
67 done
68 fi
69 done
70 log "Initial sync complete"
71
72 # clean stale entries: unpin files that no longer exist on disk
73 log "Cleaning stale entries..."
74 while IFS=' ' read -r cid path; do
75 if [ ! -f "$path" ]; then
76 log "Stale entry: $path (CID: $cid)"
77 $IPFS pin rm "$cid" 2>/dev/null || true
78 ${pkgs.gnused}/bin/sed -i "\|^[^ ]* $path$|d" "$CID_MAP"
79 fi
80 done < "$CID_MAP"
81 log "Cleanup complete"
82
83 # pin all files in a newly appeared directory (covers the inotify
84 # recursive-watch race when a brand-new subdir is created + filled fast)
85 scan_new_dir() {
86 local dir="$1"
87 log "Scanning new directory: $dir"
88 ${pkgs.findutils}/bin/find "$dir" -type f | while read -r f; do
89 add_file "$f"
90 done
91 }
92
93 # watch for changes
94 log "Watching directories: ${lib.concatStringsSep ", " watchDirs}"
95 $INOTIFYWAIT -m -r --format $'%w\t%e\t%f' \
96 -e close_write \
97 -e moved_to \
98 -e moved_from \
99 -e delete \
100 -e create \
101 ${lib.escapeShellArgs watchDirs} |
102 while IFS=$'\t' read -r dir event file; do
103 path="''${dir}''${file}"
104 case "$event" in
105 CREATE,ISDIR*|MOVED_TO,ISDIR*)
106 scan_new_dir "$path"
107 ;;
108 CLOSE_WRITE*|MOVED_TO*)
109 add_file "$path"
110 ;;
111 DELETE*|MOVED_FROM*)
112 remove_file "$path"
113 ;;
114 esac
115 done
116 '';
117
118 uploadServer = pkgs.writers.writePython3Bin "ipfs-upload-server" {
119 flakeIgnore = [ "E501" ];
120 } ./ipfs_upload.py;
121
122 # Periodic reconciliation so cid-map/pins stay in sync with the filesystem
123 # even when the inotify watcher drops events:
124 # reverse - unpin + drop cid-map entries for files no longer on disk.
125 # forward - pin files present on disk but missing from cid-map. The watcher
126 # loop is single-threaded and blocks 1-2 min on each multi-GB
127 # `ipfs add`; while blocked, a concurrent upload's CREATE,ISDIR +
128 # MOVED_TO for a brand-new subdir can overflow the inotify queue
129 # and be dropped, leaving a fully-written file never pinned. The
130 # forward sweep heals that within an hour.
131 reconcileScript = pkgs.writers.writeBash "ipfs-pin-reconcile" ''
132 set -efu
133
134 IPFS="${pkgs.kubo}/bin/ipfs"
135 CID_MAP="${cidMapFile}"
136
137 [ -f "$CID_MAP" ] || exit 0
138
139 # Reverse: rebuild cid-map, unpinning entries whose file is gone. One pass
140 # avoids sed regex issues with special chars like [ ] in paths. Small race
141 # with the watcher's concurrent appends -- a line written during this loop
142 # may be lost, but is re-added on the next inotify event or watcher restart.
143 tmp=$(${pkgs.coreutils}/bin/mktemp)
144 removed=0
145 while IFS=' ' read -r cid path; do
146 [ -n "$cid" ] || continue
147 if [ -e "$path" ]; then
148 echo "$cid $path" >> "$tmp"
149 else
150 $IPFS pin rm "$cid" >/dev/null 2>&1 || true
151 removed=$((removed + 1))
152 fi
153 done < "$CID_MAP"
154
155 if [ "$removed" -gt 0 ]; then
156 ${pkgs.coreutils}/bin/chmod 0644 "$tmp"
157 ${pkgs.coreutils}/bin/mv "$tmp" "$CID_MAP"
158 echo "reconciled: removed $removed stale entries"
159 else
160 ${pkgs.coreutils}/bin/rm -f "$tmp"
161 fi
162
163 # Forward: pin on-disk files missing from cid-map. Only files older than
164 # 10 min -- a file that old and still absent from cid-map was dropped by the
165 # watcher, not in-flight. No lock against the watcher: concurrent
166 # `add --nocopy` of the same file is idempotent (verified), so a rare
167 # double-add is harmless.
168 for dir in ${lib.escapeShellArgs watchDirs}; do
169 [ -d "$dir" ] || continue
170 ${pkgs.findutils}/bin/find "$dir" -type f -mmin +10 | while read -r f; do
171 case "$(${pkgs.coreutils}/bin/basename "$f")" in
172 .* | *.part | *.tmp | *.synced.* | *.aria2) continue ;;
173 esac
174 if ${pkgs.gnugrep}/bin/grep -q " $f$" "$CID_MAP" 2>/dev/null; then
175 continue
176 fi
177 cid=$($IPFS add --nocopy --pin --quieter "$f" 2>/dev/null) || {
178 echo "reconcile: failed to add $f"
179 continue
180 }
181 echo "$cid $f" >> "$CID_MAP"
182 echo "reconcile: pinned missed file $f -> $cid"
183 done
184 done
185 '';
186in
187{
188 # symlink /var/download into IPFS root so --nocopy works
189 systemd.tmpfiles.rules = [
190 "L+ /var/lib/ipfs/download - - - - /var/download"
191 # Staging area for ipfs-upload: same filesystem as the destination, but
192 # outside the pin-watcher's watch dirs (movies/shows/games) so inotify
193 # never fires on in-flight uploads.
194 "d /var/download/incoming 0755 ${config.services.kubo.user} ${config.services.kubo.group} -"
195 "d /var/download/incoming/uploader 0755 ${config.services.kubo.user} ${config.services.kubo.group} -"
196 # Let the ipfs-upload service write into /var/download/games. Owner
197 # = ipfs so the service has write; group = users + setgid so existing
198 # CLI users (lass) keep write via group and new uploads inherit
199 # group=users.
200 "z /var/download/games 2775 ${config.services.kubo.user} users -"
201 ];
202
203 services.kubo = {
204 enable = true;
205 settings = {
206 # kubo 0.40 deprecated Reprovider in favor of Provide; the nixpkgs
207 # module still injects a default Reprovider block, so null it out
208 # explicitly or kubo refuses to start.
209 Reprovider = null;
210 Experimental.FilestoreEnabled = true;
211 Addresses = {
212 API = [
213 "/ip4/127.0.0.1/tcp/5001"
214 "/ip6/::1/tcp/5001"
215 ];
216 Gateway = [
217 "/ip4/0.0.0.0/tcp/8089"
218 "/ip6/::/tcp/8089"
219 ];
220 };
221 Datastore.StorageMax = "100GB";
222 # limit bandwidth: 5MB/s out, 5MB/s in
223 Swarm.ConnMgr = {
224 LowWater = 100;
225 HighWater = 400;
226 GracePeriod = "20s";
227 };
228 Swarm.Transports.Network.TCP = true;
229 Swarm.Transports.Network.QUIC = true;
230 Swarm.ResourceMgr = {
231 Enabled = true;
232 MaxMemory = "2GB";
233 };
234 # disable relay to reduce overhead
235 Swarm.RelayClient.Enabled = false;
236 Swarm.RelayService.Enabled = false;
237 # autoclient: DHT client (no server queries) + IPNI/cid.contact HTTP
238 # announcements. Content is discoverable by both DHT peers and lassie
239 # (which uses IPNI for provider discovery).
240 Routing.Type = "autoclient";
241 Provide = {
242 Strategy = "pinned";
243 DHT.Interval = "12h";
244 };
245 };
246 };
247
248 # kubo takes 70s+ to shut down gracefully (longer the longer it ran). A
249 # changed ipfs unit is restarted in switch-to-configuration's start phase
250 # instead of stopped up front, so the other changed units don't stay down
251 # for the whole stop phase waiting on it.
252 systemd.services.ipfs.stopIfChanged = false;
253
254 boot.kernel.sysctl."fs.inotify.max_user_watches" = 1048576;
255
256 systemd.services.ipfs-pin-watcher = {
257 description = "Auto-pin files to IPFS using inotify";
258 after = [ "ipfs.service" ];
259 requires = [ "ipfs.service" ];
260 wantedBy = [ "multi-user.target" ];
261 serviceConfig = {
262 Environment = [ "IPFS_PATH=/var/lib/ipfs" ];
263 ExecStart = pinWatcherScript;
264 Restart = "always";
265 RestartSec = "10s";
266 User = config.services.kubo.user;
267 Group = config.services.kubo.group;
268 SupplementaryGroups = [ "radio_container" ];
269 };
270 };
271
272 systemd.services.ipfs-upload = {
273 description = "HTTP upload endpoint that writes files under /var/lib/ipfs/download and pins them";
274 after = [ "ipfs.service" ];
275 requires = [ "ipfs.service" ];
276 wantedBy = [ "multi-user.target" ];
277 serviceConfig = {
278 Environment = [
279 "IPFS_PATH=/var/lib/ipfs"
280 "IPFS_BIN=${pkgs.kubo}/bin/ipfs"
281 "DOWNLOAD_ROOT=/var/lib/ipfs/download"
282 "BIND=127.0.0.1"
283 "PORT=8090"
284 ];
285 ExecStart = "${uploadServer}/bin/ipfs-upload-server";
286 Restart = "always";
287 RestartSec = "10s";
288 User = config.services.kubo.user;
289 Group = config.services.kubo.group;
290 # /var/download is owned by ipfs:ipfs on neoprism, so no extra
291 # groups needed. Existing subdirs owned by other users (e.g. games
292 # → lass:users) are not writable by this service; uploads to those
293 # paths will get a 500 unless ownership is adjusted out-of-band.
294 };
295 };
296
297 systemd.services.ipfs-pin-reconcile = {
298 description = "Reconcile pins / cid-map against the filesystem";
299 after = [ "ipfs.service" ];
300 requires = [ "ipfs.service" ];
301 serviceConfig = {
302 Type = "oneshot";
303 Environment = [ "IPFS_PATH=/var/lib/ipfs" ];
304 ExecStart = reconcileScript;
305 User = config.services.kubo.user;
306 Group = config.services.kubo.group;
307 };
308 };
309
310 systemd.timers.ipfs-pin-reconcile = {
311 description = "Reconcile pins / cid-map hourly";
312 wantedBy = [ "timers.target" ];
313 timerConfig = {
314 OnBootSec = "15min";
315 OnUnitActiveSec = "1h";
316 Unit = "ipfs-pin-reconcile.service";
317 };
318 };
319
320 networking.firewall.allowedTCPPorts = [
321 4001 # IPFS swarm
322 8089 # IPFS gateway
323 ];
324 networking.firewall.allowedUDPPorts = [
325 4001 # IPFS swarm (QUIC)
326 ];
327
328 # Shadow `ipfs` in PATH with a wrapper that rejects `ipfs add` without
329 # --nocopy. The kubo daemon and pin-watcher use absolute store paths, so
330 # they are unaffected. This blocks confused agents/users from creating
331 # blockstore-only duplicates of files that already live in
332 # /var/lib/ipfs/download.
333 environment.systemPackages = [
334 (lib.hiPrio (
335 pkgs.writeShellScriptBin "ipfs" ''
336 if [ "''${1-}" = "add" ]; then
337 for a in "$@"; do
338 case "$a" in
339 --nocopy|--nocopy=true) exec ${pkgs.kubo}/bin/ipfs "$@" ;;
340 esac
341 done
342 echo "refused: 'ipfs add' without --nocopy is forbidden on this host." >&2
343 echo " Use 'ipfs add --nocopy <path>' so the file is referenced via filestore" >&2
344 echo " instead of duplicated into the blockstore." >&2
345 exit 1
346 fi
347 exec ${pkgs.kubo}/bin/ipfs "$@"
348 ''
349 ))
350 ];
351}