Fix FreeBSD build and add chroot + Capsicum security hardening

FreeBSD User

8160d21332724acc90b96ff871a084173b540c66

diff --git a/Makefile b/Makefile
index ee3655c..7488503 100644
--- a/Makefile
+++ b/Makefile
@@ -1,5 +1,5 @@
 SCHEME ?= scheme
-JERBOA ?= $(HOME)/mine/jerboa/lib
+JERBOA ?= $(HOME)/jerboa/lib
 LIBDIRS = lib:$(JERBOA)
 
 .PHONY: all build test clean jdns jdns-data
diff --git a/bin/jdns-data.ss b/bin/jdns-data.ss
index 383871b..dde1841 100644
--- a/bin/jdns-data.ss
+++ b/bin/jdns-data.ss
@@ -1,3 +1,3 @@
 #!chezscheme
 (import (chezscheme) (jerboa-dns main))
-(apply run-jdns-data! (cdr (command-line)))
+(run-jdns-data! (cdr (command-line)))
diff --git a/bin/jdns.ss b/bin/jdns.ss
index 2a42848..fc5ec41 100644
--- a/bin/jdns.ss
+++ b/bin/jdns.ss
@@ -1,3 +1,3 @@
 #!chezscheme
 (import (chezscheme) (jerboa-dns main))
-(apply run-jdns! (cdr (command-line)))
+(run-jdns! (cdr (command-line)))
diff --git a/lib/jerboa-dns/cdb.sls b/lib/jerboa-dns/cdb.sls
index 3e1cbcd..7222f4d 100644
--- a/lib/jerboa-dns/cdb.sls
+++ b/lib/jerboa-dns/cdb.sls
@@ -17,7 +17,7 @@
     cdb-hash
 
     ;; Reader
-    open-cdb-reader cdb-reader?
+    open-cdb-reader open-cdb-reader/bytevector cdb-reader?
     cdb-reader-close!
     cdb-find
     cdb-find-all
@@ -80,6 +80,11 @@
                 (loop (+ off n)))))))
       (make-cdb-reader bv #f)))
 
+  (define (open-cdb-reader/bytevector bv)
+    ;; Create a CDB reader from a pre-read bytevector.
+    ;; Used when the file was read via openat() in Capsicum capability mode.
+    (make-cdb-reader bv #f))
+
   (define (get-file-size path)
     (let ([p (open-file-input-port path)])
       (let ([len (port-length p)])
diff --git a/lib/jerboa-dns/server.sls b/lib/jerboa-dns/server.sls
index f6ec715..fb573ed 100644
--- a/lib/jerboa-dns/server.sls
+++ b/lib/jerboa-dns/server.sls
@@ -2,8 +2,22 @@
 ;;; (jerboa-dns server) — UDP DNS server with privilege drop and sandboxing
 ;;;
 ;;; Translates djbdns server.c + tinydns.c startup sequence.
-;;; Enhanced with Landlock filesystem confinement and seccomp syscall
-;;; filtering after privilege drop.
+;;; Security layers (applied in order):
+;;;   1. Bind socket to port (requires root for port 53)
+;;;   2. chroot() to data directory (FreeBSD) or chdir (Linux)
+;;;   3. Drop privileges via setgid/setuid
+;;;   4. FreeBSD: cap_rights_limit on non-socket fds (restrict operations)
+;;;   5. Linux: Landlock filesystem restriction (if available)
+;;;
+;;; After sandboxing, the process can only:
+;;;   - Receive/send UDP on the bound socket
+;;;   - Read files in the chroot (data.cdb for zone lookups)
+;;;   - Write to stderr (logging)
+;;;
+;;; Capsicum cap_enter() is NOT used because sendto() on UDP sockets
+;;; requires routing table access which is blocked in capability mode
+;;; (ECAPMODE). Instead we use chroot + privdrop + cap_rights_limit,
+;;; matching djbdns's own security model.
 
 (library (jerboa-dns server)
   (export
@@ -20,19 +34,43 @@
     (jerboa-dns response)
     (jerboa-dns lookup))
 
+  ;; ========== Platform Detection ==========
+
+  (define (string-contains-substr str sub)
+    (let ([slen (string-length str)]
+          [sublen (string-length sub)])
+      (let lp ([i 0])
+        (cond
+          [(> (+ i sublen) slen) #f]
+          [(string=? (substring str i (+ i sublen)) sub) #t]
+          [else (lp (+ i 1))]))))
+
+  (define *machine-type-str* (symbol->string (machine-type)))
+  (define *on-freebsd* (string-contains-substr *machine-type-str* "fb"))
+  (define *on-linux*   (string-contains-substr *machine-type-str* "le"))
+
   ;; ========== Server Configuration ==========
 
   (define-record-type server-config
     (fields
       ip         ;; string (bind address, default "0.0.0.0")
       port       ;; integer (default 53)
-      root-dir   ;; string (chdir here after binding)
+      root-dir   ;; string (chroot here after binding)
       uid        ;; integer or #f (setuid after binding)
       gid        ;; integer or #f (setgid after binding)
       data-file  ;; string (CDB filename, default "data.cdb")
     )
     (nongenerative server-config))
 
+  ;; ========== Load libc ==========
+  ;; On FreeBSD, socket/network functions require explicit libc loading.
+
+  (define _libc
+    (or (guard (e [#t #f]) (load-shared-object "libc.so.7"))
+        (guard (e [#t #f]) (load-shared-object "libc.so.6"))
+        (guard (e [#t #f]) (load-shared-object "libc.so"))
+        (guard (e [#t #f]) (load-shared-object ""))))
+
   ;; ========== FFI for socket operations ==========
 
   (define c-socket    (foreign-procedure "socket" (int int int) int))
@@ -49,27 +87,66 @@
   (define c-setgid    (foreign-procedure "setgid" (unsigned) int))
   (define c-chdir     (foreign-procedure "chdir" (string) int))
 
-  ;; Constants
+  ;; ========== FFI for chroot (FreeBSD/Linux) ==========
+
+  (define c-chroot
+    (guard (e [#t (lambda (path) -1)])
+      (foreign-procedure "chroot" (string) int)))
+
+  ;; ========== FFI for Capsicum cap_rights_limit ==========
+
+  (define c-cap-rights-limit
+    (if *on-freebsd*
+      (guard (e [#t (lambda (fd rights) -1)])
+        (foreign-procedure "cap_rights_limit" (int void*) int))
+      (lambda (fd rights) -1)))
+
+  ;; ========== Platform-specific Constants ==========
+
   (define AF_INET 2)
   (define SOCK_DGRAM 2)
-  (define SOL_SOCKET 1)
-  (define SO_REUSEADDR 2)
+
+  ;; SOL_SOCKET: 0xffff on FreeBSD/macOS, 1 on Linux
+  (define SOL_SOCKET    (if *on-freebsd* #xffff 1))
+  ;; SO_REUSEADDR: 4 on FreeBSD, 2 on Linux
+  (define SO_REUSEADDR  (if *on-freebsd* 4 2))
+
   (define SOCKADDR_IN_SIZE 16)
   (define INET_ADDRSTRLEN 16)
   (define MAX-PACKET 512)  ;; DNS UDP limit
 
+  ;; Capsicum rights constants (raw bits, without CAPRIGHT version marker)
+  ;; pack-cap-rights adds the (1<<57) / (1<<58) index markers.
+  (define CAP_READ       #x0000000000000001)
+  (define CAP_WRITE      #x0000000000000002)
+  (define CAP_SEEK       #x000000000000000c)
+  (define CAP_FSTAT      #x0000000000080000)
+  (define CAP_LOOKUP     #x0000000000000400)
+  ;; Index-1 rights
+  (define CAP_EVENT      #x0000000000000020)
+
+  ;; cap_rights_t layout: cr_rights[0] = (1<<57) | idx0-bits
+  ;;                       cr_rights[1] = (1<<58) | idx1-bits
+  (define CAP_RIGHTS_SIZE 16)
+
   ;; ========== Socket Helpers ==========
 
   (define (make-sockaddr-in address port)
     (let ([buf (foreign-alloc SOCKADDR_IN_SIZE)])
-      ;; Zero
+      ;; Zero the buffer
       (do ([i 0 (+ i 1)]) ((= i SOCKADDR_IN_SIZE))
         (foreign-set! 'unsigned-8 buf i 0))
-      ;; sin_family = AF_INET
-      (foreign-set! 'unsigned-short buf 0 AF_INET)
-      ;; sin_port = htons(port)
+      ;; sin_family — layout differs between FreeBSD and Linux
+      (if *on-freebsd*
+        (begin
+          ;; FreeBSD: sin_len (uint8) at offset 0, sin_family (uint8) at offset 1
+          (foreign-set! 'unsigned-8 buf 0 SOCKADDR_IN_SIZE)
+          (foreign-set! 'unsigned-8 buf 1 AF_INET))
+        ;; Linux: sin_family (uint16) at offset 0
+        (foreign-set! 'unsigned-short buf 0 AF_INET))
+      ;; sin_port = htons(port) at offset 2 (same on both)
       (foreign-set! 'unsigned-short buf 2 (c-htons port))
-      ;; sin_addr (offset 4)
+      ;; sin_addr at offset 4 (same on both)
       (when (= (c-inet-pton AF_INET address (+ buf 4)) 0)
         (foreign-free buf)
         (error 'make-sockaddr-in "invalid address" address))
@@ -79,12 +156,74 @@
     (c-ntohs (foreign-ref 'unsigned-short sa 2)))
 
   (define (sockaddr-in-addr-str sa)
+    ;; Read the IP address as a dotted-quad string from sockaddr_in.
+    ;; inet_ntop writes a null-terminated C string to the buffer.
     (let ([out (foreign-alloc INET_ADDRSTRLEN)])
       (c-inet-ntop AF_INET (+ sa 4) out INET_ADDRSTRLEN)
-      (let ([s (foreign-ref 'string out 0)])
+      (let ([s (foreign-cstring->string out)])
         (foreign-free out)
         s)))
 
+  (define (foreign-cstring->string ptr)
+    ;; Read a null-terminated C string from foreign memory.
+    (let loop ([i 0] [chars '()])
+      (let ([b (foreign-ref 'unsigned-8 ptr i)])
+        (if (= b 0)
+          (list->string (reverse chars))
+          (loop (+ i 1) (cons (integer->char b) chars))))))
+
+  ;; ========== Capsicum fd Restriction (FreeBSD) ==========
+
+  (define (pack-cap-rights idx0-bits idx1-bits)
+    ;; Pack capability rights into a cap_rights_t structure.
+    (let ([mem (foreign-alloc CAP_RIGHTS_SIZE)])
+      ;; cr_rights[0] = (1 << 57) | index-0 rights
+      (foreign-set! 'unsigned-64 mem 0
+        (bitwise-ior (bitwise-arithmetic-shift-left 1 57) idx0-bits))
+      ;; cr_rights[1] = (1 << 58) | index-1 rights
+      (foreign-set! 'unsigned-64 mem 8
+        (bitwise-ior (bitwise-arithmetic-shift-left 1 58) idx1-bits))
+      mem))
+
+  (define (cap-rights-limit! fd idx0-bits idx1-bits)
+    ;; Restrict fd to only the specified Capsicum rights.
+    ;; Works regardless of whether the process is in capability mode.
+    (let ([rights (pack-cap-rights idx0-bits idx1-bits)])
+      (let ([rc (c-cap-rights-limit fd rights)])
+        (foreign-free rights)
+        (when (< rc 0)
+          (display (format "jdns: warning: cap_rights_limit failed for fd ~a\n" fd)
+                   (current-error-port))
+          (flush-output-port (current-error-port))))))
+
+  (define (apply-capsicum-fd-restrictions!)
+    ;; Restrict stdio fds using Capsicum cap_rights_limit.
+    ;; These restrictions are permanent and enforced at the kernel level,
+    ;; independent of capability mode.
+    ;;
+    ;; Note: We restrict stdin/stdout/stderr but must include seek rights
+    ;; because Chez Scheme probes fd capabilities (e.g., fstat, lseek)
+    ;; when initializing ports. Without these, port operations may fail.
+
+    ;; Stdin: read + fstat + seek
+    (cap-rights-limit! 0
+      (bitwise-ior CAP_READ CAP_FSTAT CAP_SEEK)
+      CAP_EVENT)
+
+    ;; Stdout: write + fstat + seek
+    (cap-rights-limit! 1
+      (bitwise-ior CAP_WRITE CAP_FSTAT CAP_SEEK)
+      CAP_EVENT)
+
+    ;; Stderr: write + fstat + seek
+    (cap-rights-limit! 2
+      (bitwise-ior CAP_WRITE CAP_FSTAT CAP_SEEK)
+      CAP_EVENT)
+
+    (display "jdns: capsicum fd restrictions applied\n"
+             (current-error-port))
+    (flush-output-port (current-error-port)))
+
   ;; ========== Query Processing ==========
   ;; One response-state reused across queries (like djbdns global)
 
@@ -96,7 +235,14 @@
         (bytevector-u8-set! pkt i (foreign-ref 'unsigned-8 pkt-buf i)))
 
       (guard (e [#t
-                 ;; On any error, send SERVFAIL
+                 ;; On any error, send SERVFAIL and log
+                 (guard (e2 [#t (void)])
+                   (display (format "jdns: query error: ~a\n"
+                             (if (message-condition? e)
+                               (condition-message e)
+                               "unknown"))
+                            (current-error-port))
+                   (flush-output-port (current-error-port)))
                  (let ([err-rs (new-response-state)])
                    (when (>= pkt-len 12)
                      (response-query! err-rs (make-bytevector 1 0) DNS-T-A DNS-C-IN)
@@ -121,7 +267,7 @@
                   (when cdb
                     ;; Build client IP bytevector for location lookup
                     (let ([client-ip (make-bytevector 4 0)])
-                      ;; Extract from sockaddr
+                      ;; Extract from sockaddr (sin_addr at offset 4)
                       (do ([i 0 (+ i 1)]) ((= i 4))
                         (bytevector-u8-set! client-ip i
                           (foreign-ref 'unsigned-8 (+ client-addr 4) i)))
@@ -194,12 +340,27 @@
                  (current-error-port))
         (flush-output-port (current-error-port))
 
-        ;; 4. chdir to data directory
+        ;; 4. chroot + chdir to data directory
+        ;;    chroot restricts filesystem access to this directory tree.
+        ;;    Must be done before dropping privileges (requires root).
         (when root-dir
-          (when (= (c-chdir root-dir) -1)
-            (error 'run-server! "cannot chdir" root-dir)))
-
-        ;; 5. Drop privileges (gid before uid)
+          (let ([chroot-ok (= (c-chroot root-dir) 0)])
+            (if chroot-ok
+              (begin
+                (c-chdir "/")
+                (display (format "jdns: chroot to ~a\n" root-dir)
+                         (current-error-port))
+                (flush-output-port (current-error-port)))
+              (begin
+                ;; chroot failed (not root?) — fall back to chdir
+                (when (= (c-chdir root-dir) -1)
+                  (error 'run-server! "cannot chdir" root-dir))
+                (display (format "jdns: chdir to ~a (chroot unavailable)\n" root-dir)
+                         (current-error-port))
+                (flush-output-port (current-error-port))))))
+
+        ;; 5. Drop privileges (gid before uid, as setuid may remove
+        ;;    the ability to call setgid)
         (when gid
           (when (= (c-setgid gid) -1)
             (error 'run-server! "cannot setgid" gid)))
@@ -213,7 +374,17 @@
                    (current-error-port))
           (flush-output-port (current-error-port)))
 
-        ;; 6. Main loop
+        ;; 6. Apply OS-level sandbox (defense in depth)
+        (cond
+          [*on-freebsd*
+           ;; Capsicum: restrict stdio fd rights (kernel-enforced, permanent)
+           ;; chroot already restricts filesystem, cap_rights_limit hardens fds
+           (apply-capsicum-fd-restrictions!)]
+          [*on-linux*
+           ;; Landlock: restrict filesystem access (if available)
+           (enter-landlock-sandbox!)])
+
+        ;; 7. Main loop
         (let ([recv-buf (foreign-alloc 65536)]
               [client-addr (foreign-alloc SOCKADDR_IN_SIZE)]
               [addrlen-buf (foreign-alloc 4)]
@@ -238,4 +409,31 @@
 
             (loop))))))
 
+  ;; ========== Landlock Sandbox (Linux) ==========
+
+  (define (enter-landlock-sandbox!)
+    ;; On Linux, restrict filesystem access to current directory only.
+    ;; Uses Landlock ABI if available (Linux 5.13+).
+    ;; Graceful fallback if not available.
+    (guard (e [#t
+               (display "jdns: landlock not available — running without filesystem sandbox\n"
+                        (current-error-port))
+               (flush-output-port (current-error-port))])
+      ;; Try to load the Jerboa Landlock module dynamically
+      (let ([ll-available? (guard (e [#t #f])
+                             (eval '(begin
+                                      (import (std security landlock))
+                                      (landlock-available?))
+                                   (interaction-environment)))])
+        (when ll-available?
+          (eval '(begin
+                   (import (std security landlock))
+                   (let ([rs (make-landlock-ruleset)])
+                     (landlock-add-read-only! rs ".")
+                     (landlock-install! rs)))
+                (interaction-environment))
+          (display "jdns: landlock filesystem sandbox active\n"
+                   (current-error-port))
+          (flush-output-port (current-error-port))))))
+
   ) ;; end library
diff --git a/vs.md b/vs.md
new file mode 100644
index 0000000..5709259
--- /dev/null
+++ b/vs.md
@@ -0,0 +1,45 @@
+# jerboa-dns (Scheme) vs djbdns (C) — Security Comparison
+
+## Where jerboa-dns is More Secure
+
+**Memory-safe DNS parsing.** The entire protocol, CDB, response, lookup, and zone-compiler modules (7 of 8 files, ~1500 lines) use Chez Scheme bytevectors with automatic bounds checking. A malformed DNS packet that would be a buffer overflow in C becomes a caught exception here. DJB was famously careful, but his bounds checks are manual — one mistake and you have a CVE. Here, the runtime enforces it.
+
+**No integer overflow in hash/length math.** Scheme has arbitrary-precision integers. CDB hash computation, packet length arithmetic, domain label walking — none of these can silently wrap around. In djbdns's C, these are `uint32` operations where wrapping is a known class of bugs.
+
+**GC eliminates use-after-free and double-free.** The CDB reader, response state, bytevectors — all garbage collected. In djbdns, the global response buffer and CDB mmap'd regions require careful lifetime management.
+
+## Where djbdns is More Secure
+
+**Massive runtime attack surface.** djbdns is ~2000 lines of C with zero library dependencies (not even libc for the critical path — DJB reimplemented string ops). jerboa-dns pulls in the entire Chez Scheme runtime: JIT compiler, garbage collector, foreign function interface, port system. A vulnerability in Chez itself (JIT codegen, GC heap corruption) would be exploitable. DJB's attack surface is tiny by comparison.
+
+**The FFI boundary is just as unsafe as C.** The server module has 44 FFI operations — `foreign-alloc`, `foreign-ref`, `foreign-set!` with raw pointers. These have zero bounds checking. The `foreign-cstring->string` helper scans memory until a null byte — if `inet_ntop` somehow didn't null-terminate, that's an unbounded read, same as C. The sockaddr struct packing is manual offset math, same as C.
+
+**GC pauses under load.** djbdns has deterministic, bounded response times — no allocator locks, no stop-the-world pauses. jerboa-dns allocates bytevectors per-query (CDB reader, response state, domain copies). Under heavy query load, GC pauses could cause packet drops or timing side-channels.
+
+**Larger binary = larger ROP gadget surface.** A Chez Scheme process has a JIT code heap, a rich standard library, and `eval` capabilities. An attacker who gets code execution has far more to work with than in a stripped, static djbdns binary.
+
+## Roughly Equal
+
+**OS-level sandboxing.** Both use the same model: bind socket, chroot, drop privileges. jerboa-dns adds `cap_rights_limit` on stdio fds on FreeBSD, which djbdns doesn't do. Both are constrained by the same UDP `sendto` vs Capsicum limitation.
+
+**CDB atomic updates.** Both open-read-close the CDB per query, allowing live zone updates via rename.
+
+**Protocol correctness.** The lookup engine is a direct translation of `tdlookup.c` — same logic, same CNAME-following depth limit, same wildcard walk.
+
+## Summary Table
+
+| Dimension | jerboa-dns (Scheme) | djbdns (C) |
+|---|---|---|
+| Buffer overflow in DNS parsing | Impossible (runtime bounds check) | Possible (manual checks) |
+| Integer overflow | Impossible (bignums) | Possible (uint32 wrapping) |
+| Use-after-free | Impossible (GC) | Possible (manual lifetime) |
+| FFI/socket code safety | Same as C | Same |
+| Runtime attack surface | Large (Chez JIT + GC + stdlib) | Tiny (~2000 LOC, no deps) |
+| Latency determinism | GC pauses | Deterministic |
+| Exploit gadgets | Abundant (JIT heap, eval) | Minimal (stripped static) |
+
+## Bottom Line
+
+The DNS *application logic* is meaningfully safer — you can't corrupt memory through a malformed query. But the *system boundary* (FFI, runtime) is a larger target than djbdns's hand-rolled C. DJB traded developer convenience for minimal attack surface. jerboa-dns trades attack surface for memory safety in the application layer. Neither dominates the other — they're different points on the security trade-off curve.
+
+The biggest real-world difference: if someone finds a new class of DNS packet exploit, jerboa-dns survives (bounds-checked bytevectors) and djbdns might not. If someone finds a Chez Scheme runtime vulnerability, jerboa-dns is exploitable and djbdns is immune.