Skip to content

jperon/custos

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

534 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

CustosVirginum

Inline DNS filter on Linux bridge, written in MoonScript and executed by LuaJIT. Blocks all DNS traffic except explicitly allowed domains, logs L2/L3/L4/L7 information, and dynamically builds nftables allowlists as DNS resolutions occur.

Packet parsing uses pure LuaJIT FFI pointer arithmetic for L3/L4/L7 decoding — all without any C compilation step.


Architecture

┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│  Linux bridge machine                                                                        │
│                                                                                              │
│  nftables (kernel)                                                                           │
│  ├── policy DROP + REJECT LAN                                                                │
│  ├── set ip4_allowed   { ipv4_src . ipv4_dst  timeout 2m }                                   │
│  ├── set ip6_allowed   { ipv6_src . ipv6_dst  timeout 2m }                                   │
│  ├── set authenticated_macs{ ether_addr timeout <idle_timeout>}                              │
│  ├── set authenticated_ips { ipv4_addr timeout <idle_timeout>}                               │
│  ├── set authenticated_ips6{ ipv6_addr timeout <idle_timeout>}                               │
│  ├── TCP :80 LAN SYN    → NFQUEUE_CAPTIVE    (portail captif)                                │
│  ├── TCP :33443          → NFQUEUE_AUTH       (extrait MAC/IP)                               │
│  ├── TCP/UDP :443        → NFQUEUE_SNI    (verdict SNI TLS/QUIC, optionnel)              │
│  ├── SIP/STUN            → NFQUEUE_SIP        (signalisation VoIP, optionnel)                │
│  ├── Reject résiduel     → NFQUEUE_REJECT     (reject, rate-limité)                          │
│  ├── UDP/TCP :53 src=LAN → NFQUEUE_QUESTIONS  (questions)                                    │
│  └── UDP/TCP :53 dst=LAN → NFQUEUE_RESPONSES  (réponses)                                     │
│                                                                                              │
│  LuaJIT (userspace)  BRIDGE_IFNAME=<br>                                                      │
│  ├── main.lua           supervisor + fork                                                    │
│  ├── mac_learner        table IP→MAC (socket Unix)                                           │
│  ├── worker_arp_sniffer ARP/NDP passif → pipe learn (22 B)                                   │
│  ├── worker_questions ── pipe question_response (43 B, rule_id+timeout) ──► worker_responses │
│  │   parse L2/L3/L4/L7      ├─ pipe learn (22 B)   → mac_learner                             │
│  │   rules (conditions+actions)  └─ pipe events    → worker_events                           │
│  │   log + ACCEPT/REFUSED/DNSONLY            verify txid · patch TTL · ─ pipe nft ─► worker_nft │
│  ├── worker_nft         — sérialise les insertions nft + ACK par worker                      │
│  ├── worker_events      — agrège/persiste les événements DNS                                 │
│  ├── worker_auth_queue ─ pipe learn (22 B) ──► mac_learner                                   │
│  ├── worker AUTH       — HTTPS WolfSSL (port 33443) : portail captif + admin /admin/*        │
│  ├── worker_captive    — TCP/80 SYN → AF_PACKET 302                                          │
│  ├── worker_tls        — verdict SNI TLS/QUIC (443, optionnel) ─ pipe nft ─► worker_nft      │
│  ├── worker_sip        — IP médias SDP/proxy SIP (optionnel)   ─ pipe nft ─► worker_nft      │
│  ├── worker_doh        — serveur DoH HTTPS (8443, optionnel)   ─ pipe nft ─► worker_nft      │
│  ├── worker_reject     — forge RST/ICMP admin-prohibited                                     │
│  │                                                                                           │
│  └── logs → syslog (journald / logread)                                                      │
└──────────────────────────────────────────────────────────────────────────────────────────────┘

Allowed packet flow

DNS Client (LAN)
   │  question UDP/53 → www.github.com ?
   ▼
nft FORWARD → NFQUEUE 0
   ▼
worker question : parse L2+L3+L4+DNS → qname="www.github.com"
   │  is_allowed("www.github.com") → true (suffix "github.com")
   │  log: ALLOW mac_src=aa:bb:.. src_ip=192.168.1.42 qname=www.github.com
   │  write(pipe, txid=0x1234, ip=192.168.1.42, port=54321, mac=aa:bb:cc:dd:ee:ff)
   └► NF_ACCEPT → question forwarded to resolver
   ▼
DNS Resolver (8.8.8.8) responds
   ▼
nft FORWARD → NFQUEUE 1
   ▼
worker response : drain pipe → pending[0x1234:192.168.1.42:54321] found (refused=false)
   │  parse response → A 140.82.121.4
   │  patch TTL → 60s + append EDE only when payload was modified + recalc checksums
   │  nft add element ip dns-filter ip4_allowed { 192.168.1.42 . 140.82.121.4 timeout TTL+grace (borné) }
   │  log: ALLOW action=response_patched answers=1 ttl_set=60
   └► NF_ACCEPT + modified payload
   ▼
Client receives response (TTL=60s)
   ▼
Client opens TCP connection → 140.82.121.4
   ▼
nft FORWARD : ip saddr . ip daddr @ip4_allowed accept → allowed through

Blocked packet flow

DNS Client (LAN)
   │  question UDP/53 → www.facebook.com ?
   ▼
nft FORWARD → NFQUEUE 0
   ▼
worker question : qname="www.facebook.com"
   │  is_allowed("www.facebook.com") → false
   │  log: BLOCK reason=not_in_allowlist
   │  write_refused_msg(pipe, txid=0x1234|REFUSED, ip, port, mac)
   └► NF_ACCEPT → question forwarded to resolver
   ▼
DNS Resolver (8.8.8.8) responds
   ▼
nft FORWARD → NFQUEUE 1
   ▼
worker response : drain pipe → pending[0x1234:192.168.1.42:54321] found (refused=true)
   │  transform response → RCODE=5 REFUSED + EDE code 15 "Filtered" + "Custos vigilat."
   │  replace DNS payload, strip HTTPS/SVCB if present, recalc checksums
   │  log: BLOCK action=response_refused
   └► NF_ACCEPT + REFUSED payload (client receives REFUSED + EDE)

Project Structure

Les sources sont écrites en MoonScript dans src/ ; make les compile en Lua dans lua/ (ne jamais éditer lua/ à la main).

custos/
├── cfg/
│   ├── config.moon          Exemple de configuration runtime annotée (MoonScript)
│   └── secrets.sample       Exemple de fichier de mots de passe
├── src/
│   ├── config.moon          Configuration hiérarchique runtime (/etc/custos/config.moon)
│   ├── main.moon            Superviseur : crée les pipes IPC, fork et supervise les workers
│   ├── ffi_defs.moon        Déclarations FFI centralisées
│   ├── ffi_xxhash.moon      FFI xxHash
│   ├── log.moon             Logging structuré key=value + rate-limiting
│   ├── metrics.moon         Métriques de performance par règle (verdicts, cache, TTL)
│   ├── ipc.moon             Protocole pipe question→response (msg 43 octets)
│   ├── dns_ede.moon         Helpers DNS EDE (RFC 8914) — partagés responses + DoH
│   ├── forge_dns.moon       Construction de réponses DNS forgées (vol de question)
│   ├── nft.moon             Injection sets nftables via libnftables
│   ├── nft_add_helper.moon  Helper retry/backoff pour insertions nft
│   ├── nft_rules.moon       Application du ruleset nft + compilation des règles
│   ├── nft_extra_rules.moon Règles nft supplémentaires (UCI)
│   ├── nft_queue.moon       Helpers de configuration des queues NFQUEUE
│   ├── nfq_loop.moon        Boucle générique NFQUEUE
│   ├── bridge_raw.moon      AF_PACKET : injection de frames brutes
│   ├── captive_ips.moon     Détection IPs portail captif
│   ├── ip_whitelist.moon    Gestion whitelist IP statique
│   ├── mac_learner.moon     Table IP→MAC en mémoire + socket Unix
│   ├── mac_learner_ipc.moon Client IPC pour mac_learner
│   ├── mac_prober.moon      Sondage actif ARP/NDP
│   ├── worker_questions.moon    Worker questions DNS
│   ├── worker_responses.moon    Worker réponses DNS (patch TTL/EDE, insertions nft)
│   ├── worker_nft.moon          Worker de sérialisation des insertions nft (pipe nft + ACK)
│   ├── worker_events.moon       Worker d'agrégation/persistance des événements DNS
│   ├── worker_captive.moon      Worker portail captif TCP/80
│   ├── worker_auth_queue.moon   Worker NFQUEUE port 33443 (extrait MAC/IP)
│   ├── worker_auth_pipeline.moon Pipeline d'authentification (parsing requêtes auth)
│   ├── worker_tls.moon          Worker SNI TLS/QUIC (port 443, optionnel)
│   ├── worker_sip.moon          Worker SIP/STUN (médias SDP, optionnel)
│   ├── worker_doh.moon          Worker serveur DoH HTTPS (port 8443, optionnel)
│   ├── worker_reject.moon       Worker forge RST/ICMP admin-prohibited
│   ├── worker_arp_sniffer.moon  Worker sniffer ARP/NDP passif
│   ├── lib/
│   │   ├── http.moon        Helpers HTTP (parsing requêtes/réponses)
│   │   ├── process.moon     Fork, set_process_name, signaux, shutdown
│   │   └── socket.moon      Helpers socket (FFI)
│   ├── nfq/
│   │   └── ethernet.moon    L2 : MAC src via nfq_get_packet_hw
│   ├── doh/
│   │   ├── query.moon       Résolution DoH (RFC 8484)
│   │   └── upstream.moon    Sélection upstream + sonde IPv6
│   ├── sip/
│   │   └── parser.moon      Parser léger SIP/SDP (méthode, CSeq, IP médias)
│   ├── auth/
│   │   ├── worker.moon          Worker AUTH principal
│   │   ├── server.moon          Serveur HTTPS (FFI WolfSSL) + routage /admin/*
│   │   ├── worker_conn.moon     Gestion des connexions HTTPS
│   │   ├── ffi_wolfssl.moon     FFI wrapper WolfSSL (remplace luasec)
│   │   ├── ffi_socket.moon      FFI sockets bas niveau
│   │   ├── cert.moon            Gestion certificats TLS (load_or_generate_sni)
│   │   ├── cert_generator.moon  Génération dynamique via px5g
│   │   ├── cert_cache.moon      Cache LRU/TTL pour certificats
│   │   ├── cert_parser.moon     Lecture des métadonnées de certificat
│   │   ├── sni_extractor.moon   Parser SNI (TLS ClientHello)
│   │   ├── sessions.moon        Lecture/écriture sessions.lua (MAC-primary)
│   │   ├── user_sessions.moon   Sessions par utilisateur authentifié
│   │   ├── nft_sessions.moon    Gestion sets nft pour sessions
│   │   ├── credentials.moon     Vérification PBKDF2-SHA256
│   │   ├── token.moon           Jetons de session signés (cookies)
│   │   ├── rule_user.moon       Résolution règle ↔ utilisateur
│   │   ├── html.moon            Templates HTML du portail
│   │   └── pages.moon           Pages du portail (login, succès…)
│   ├── filter/
│   │   ├── init.moon        Moteur de filtrage (load/decide/reload)
│   │   ├── rule.moon        Évaluateur de règles (conditions + actions)
│   │   ├── rule_id.moon     Identifiants stables de règles
│   │   ├── convert.moon     Convertisseurs config → types moteur
│   │   ├── updater.moon     CLI : téléchargement + compilation listes de domaines
│   │   ├── compiler_api.moon    Chargeur de conditions (auto-génération des variantes)
│   │   ├── nft_compiler.moon    Compilation des règles en expressions nft
│   │   ├── nft_dynamic_sets.moon Gestion des sets nft dynamiques
│   │   ├── localnets.moon   Détection des réseaux locaux (allow_localnets)
│   │   ├── actions/
│   │   │   ├── allow.moon     Autorise (injecte les IPs dans les sets nft)
│   │   │   ├── deny.moon      Répond REFUSED + EDE
│   │   │   ├── dnsonly.moon   DNS autorisé sans injection nft (sondes captives)
│   │   │   ├── nxdomain.moon  Répond NXDOMAIN (ex. désactivation DoH Firefox)
│   │   │   ├── cname.moon      Réécrit la réponse en CNAME vers une cible (SafeSearch)
│   │   │   ├── dns_strip.moon Retire des enregistrements de la réponse (ex. HTTPS/SVCB)
│   │   │   ├── log.moon       Journalise sans rendre de verdict
│   │   │   └── mail.moon      Notification par courriel
│   │   ├── conditions/
│   │   │   ├── from_net.moon    IP source (CIDR)
│   │   │   ├── from_subnet.moon IP source via sous-réseau config
│   │   │   ├── from_mac.moon    Adresse MAC source
│   │   │   ├── from_vlan.moon   VLAN source
│   │   │   ├── from_user.moon   Session authentifiée
│   │   │   ├── to_net.moon      IP destination (CIDR)
│   │   │   ├── to_domain.moon / to_domains.moon / to_domainlist.moon / to_domainlists.moon
│   │   │   ├── in_time.moon     Fenêtre horaire
│   │   │   ├── any_of.moon      Méta-condition OR
│   │   │   ├── not.moon         Méta-condition NOT
│   │   │   └── stolen_computer.moon  Détection d'appareil volé
│   │   │   (les variantes from_xxxs / from_xxx_list / from_xxx_lists sont
│   │   │    auto-générées à partir de from_xxx par compiler_api)
│   │   └── lib/
│   │       ├── bsearch.moon       Recherche binaire dans les listes binaires
│   │       ├── cidr_parser.moon   Parsing CIDR
│   │       ├── ipcalc.moon        Test d'appartenance CIDR
│   │       ├── load_config.moon   Chargeur de config
│   │       └── parse_domains.moon Parser multi-format de listes de domaines
│   ├── webui/
│   │   ├── router.moon      Dispatch des requêtes /admin/* vers les handlers
│   │   ├── serializer.moon  Lecture/écriture de config.moon (round-trip MoonScript)
│   │   ├── css.moon         Feuille de style de l'interface admin
│   │   ├── handlers/        dashboard, system, config, filter, rules, lists, admin_auth
│   │   └── schema/          config_schema, registry (validation des sections)
│   └── ipparse/             Bibliothèque parsing L2/L3/L4/L7 (sous-module)
├── sync/
│   ├── apply.moon           Fusion base + device → /etc/custos/config.moon
│   ├── custos-sync.sh       Synchronisation pull depuis un dépôt git central
│   └── custos-sync-push.sh  Publication push vers le dépôt central
├── .init.moon               UI redbean d'installation (empaquetée par make redbean-ui)
├── lua/                     Lua généré par moonc (ne pas éditer)
├── nft-rules/
│   └── dns-filter-bridge.nft       Ruleset nftables (bridge mode)
├── packaging/openwrt/custos/        Paquet OpenWrt (init script, custos-update, UCI)
├── libvirt/                 Homelab libvirt (3 VMs OpenWrt) pour tests E2E
├── tests/
│   ├── unit/**/*_spec.moon  Tests unitaires Busted (compilés par make test)
│   ├── helpers/             mini_busted, busted_setup
│   ├── e2e/                 Tests d'intégration nft + E2E
│   └── run_tests.moon       Runner local
├── doc/                     CONFIG.md (référence config), CHEATSHEET.md
├── .agents/                 Documentation détaillée pour agents/contributeurs
├── install-owrt.moon        Installeur OpenWrt (déploiement SSH)
├── LICENSE                  Licence MIT
├── Makefile
└── README.md

Prerequisites

OpenWrt Packages

Package Role
luajit Compiled Lua execution
lpeg Requis par MoonScript pour lire config.moon au runtime
libnetfilter-queue NFQUEUE C library
nftables nft tool + libnftables (injection des sets)
kmod-nft-queue Module noyau NFQUEUE
kmod-nft-bridge Module noyau nftables en mode bridge
libxxhash Hash xxHash (FFI, format .bin)
libwolfssl TLS/SSL library (via FFI, ffi_wolfssl)
px5g-wolfssl Dynamic TLS certificate generation
opkg install luajit lpeg libnetfilter-queue nftables \
  kmod-nft-queue kmod-nft-bridge libxxhash libwolfssl px5g-wolfssl

MoonScript est embarqué dans le dépôt (src/lib/moonscript) et déployé tel quel ; aucun paquet moonscript distant n'est requis. Pour compiler localement (make), il faut moonc + luajit (ou utiliser les .lua déjà générés).


Installation

Deployment on OpenWrt

git clone <repo> custos
cd custos

# Compile MoonScript → Lua
make

# Run unit tests (no root required)
make test

# Deploy to OpenWrt router via SSH
luajit install-owrt.lua root@<router>

The installer (install-owrt.moon):

  1. Installs required opkg packages
  2. Deploys Lua files + ruleset to /usr/share/custos/ (configurable via --dest)
  3. Installs config in /etc/custos/, the service /etc/init.d/custos, and custos-update (+ cron)
  4. Starts the service

The children/adults list files (lists/user/{enfants,adultes}.txt, lists/{enfants_allow,adultes_block}.txt) are only created empty during a new installation (when /etc/custos/config.moon is absent). If the config preexists, these lists are not (re)created: the user keeps their policy and can choose not to use them.


Configuration

Configuration runtime (config.moon)

La configuration runtime principale est /etc/custos/config.moon (surcharge partielle des défauts de src/config.moon). Elle est au format MoonScript et couvre :

  • runtime, nfqueue (dont sni, sip), nft, dns, ipc, clients, mac_learner
  • auth (port 33443, sessions, admin)
  • sni (verdict SNI 443 : inspection TLS/QUIC, placement nft, policy d'échec)
  • doh (serveur DoH HTTPS, port 8443, upstream)
  • events (persistance des événements), metrics (mesures par règle), rtp (ports RTP exclus)
  • le moteur de filtrage (filter.rules, filter.nets, filter.macs, filter.times, filter.vlans, filter.users)
  • les décisions de parcours de règles (filter.decision.first_match_wins, filter.decision.continue_to_next_rule)
  • dns.ttl_grace (grace, min, max) — timeout nft = TTL + grace, borné
  • whitelist de destinations IP (filter.dest_whitelist)
  • lists_dir — répertoire racine des listes de conditions (voir ci-dessous)

La référence exhaustive de toutes les clés est dans doc/CONFIG.md.

NFT extra rules (via UCI)

  • Il est possible d’ajouter des règles nft supplémentaires depuis UCI (section custos.main) via l’option nft_extra_rules.
  • Chaque entrée UCI est un fragment de règle nft (sans le préfixe insert rule <table> <chain> ...). Ces fragments sont insérés en tête de la chaîne forward de la table configurée au démarrage du service, et supprimés proprement à l’arrêt.
  • Exemple d’entrée UCI (une ligne par fragment) :
    • nft_extra_rules='ip saddr 10.0.0.0/8 counter log prefix "extra: " accept'
  • Remarques :
    • Les fragments doivent être des expressions nft valides pour la chaîne forward.
    • Les règles sont appliquées une seule fois au démarrage et retirées à l’arrêt ; elles ne sont pas ré-insertées lors d’un SIGHUP de rechargement du filtre.
make          # recompile après modification des sources
make reload   # envoie SIGHUP aux workers (rechargement à chaud)

Condition Lists (lists_dir)

There are two distinct list systems depending on the condition type:

System Conditions Format Evaluation
domainlists_dir + custos-update to_domainlist, to_domainlists binary (sorted xxhash64) O(log n) FFI userspace
lists_dir (plain text) from_xxx_list, from_xxx_lists text (1 item/line) kernel nft (interval tree / hash)

Domain lists can contain millions of entries and must go through custos-update to be compiled into an optimized binary format.

Other list types (networks, MACs, VLANs…) are read from text files at startup, then compiled into inline nft set expressions (ip saddr { cidr1, cidr2 }) evaluated in the kernel. Nftables optimizes these sets via interval trees (CIDRs) or hash maps (MACs, VLANs). User lists remain worker-only (sessions are dynamic and cannot be expressed as static nft).

The from_xxx_list and from_xxx_lists variants read text files organized by type in a configurable directory:

filter:
  lists_dir: "/etc/custos/lists"   -- default: /etc/custos/lists

Naming Convention

Condition Argument File Read
from_net_list "lan" list name {lists_dir}/net/lan.txt
from_net_lists {"lan","dmz"} list of names multiple files
from_mac_list "trusted" list name {lists_dir}/mac/trusted.txt
from_user_list "admins" list name {lists_dir}/user/admins.txt
from_vlan_list "corp" list name {lists_dir}/vlan/corp.txt
from_in_time_list "biz" list name {lists_dir}/in_time/biz.txt

File format: 1 valid item per line, empty lines and #comments are ignored.

Auto-generated Variants

The compiler_api loader automatically generates variants from the atomic from_xxx condition:

  • from_xxxs {"a","b"} — OR on a Lua table (no file)
  • from_xxx_list "name" — reads {lists_dir}/{xxx}/{name}.txt
  • from_xxx_lists {"n1","n2"} — OR on multiple files

It is sufficient to define from_xxx.moon; the three variants are available without additional files. Any new condition type (e.g., from_mytype.moon) automatically inherits from_mytype_list and from_mytype_lists.

requires_auth in Capabilities

A condition can declare capabilities.requires_auth = true to indicate to the nft compiler that it requires authentication sub-chains. from_user.moon does this natively; any new auth type follows the same convention without modifying nft_compiler.


Domain List Updater

src/filter/updater.moon est un outil CLI qui télécharge, parse et compile des listes de domaines au format binaire optimisé pour la recherche binaire.

# Télécharger et compiler toutes les listes définies dans config.moon
LUA_PATH="lua/?.lua;lua/?/init.lua;;" luajit lua/filter/updater.lua

# Avec un fichier de configuration alternatif :
LUA_PATH="lua/?.lua;lua/?/init.lua;;" luajit lua/filter/updater.lua --config /path/to/config.moon

# Sur OpenWrt (après installation) :
custos-update

Sources

Chaque entrée filter.sources dans config.moon peut être :

filter:
  sources:
    toulouse: {
      url: "https://dsi.ut-capitole.fr/blacklists/download/blacklists.tar.gz"
      format: "toulouse"          -- archive tar.gz multi-catégories
      subdir: "toulouse"          -- sous-dossier de domainlists_dir
    }

    ma_liste: {
      file: "/etc/custos/lists/custom/ma-liste.txt"
      format: "simple"            -- un domaine par ligne
      output: "/etc/custos/lists/custom/ma-liste.bin"
    }

Listes personnalisées

Positionner filter.custom_lists_dir dans config.moon pour activer le scan automatique de fichiers .txt :

filter:
  domainlists_dir: "/etc/custos/lists"
  custom_lists_dir: "/etc/custos/lists/custom"

Chaque fichier custom/*.txt (un domaine par ligne, # pour les commentaires) est converti en custom/*.bin. Les originaux sont conservés.

Les listes sont référençables dans les règles :

conditions:
  { to_domainlist: "custom/ma-liste" }

custos-update (OpenWrt)

L'installeur (install-owrt.moon) déploie /usr/sbin/custos-update et configure une tâche cron quotidienne (0 4 * * *) pour la mise à jour automatique des listes.


Running

On OpenWrt

# Start the service
/etc/init.d/custos start

# Stop the service
/etc/init.d/custos stop

# Restart the service
/etc/init.d/custos restart

# View logs
logread -e custos

Example log:

[1718100000] [1234] INFO  action=dns-filter_start version=1.0.0
[1718100001] [1235] INFO  action=queue_listening queue=0
[1718100001] [1236] INFO  action=queue_listening queue=1
[1718100010] [1235] ALLOW mac_src=aa:bb:cc:dd:ee:ff in_if=3 src_ip=192.168.1.42
                          dst_ip=8.8.8.8 src_port=54321 dst_port=53
                          txid=0x1234 qname=www.github.com qtype=A
[1718100010] [1236] ALLOW action=response_patched src_ip=8.8.8.8
                          dst_ip=192.168.1.42 txid=0x1234
                          qnames=www.github.com answers=2 ttl_set=60
[1718100015] [1235] BLOCK mac_src=aa:bb:cc:dd:ee:ff src_ip=192.168.1.42
                          qname=www.facebook.com qtype=A rule=r_blocages_prioritaires
                          list=toulouse/malware reason="Denied by rule: Blocages prioritaires"

IPC Protocols

Pipe question → response (question_response, 43 octets)

The Unix pipe (created before fork()) carries 43-byte messages. Atomicity is guaranteed by POSIX for messages ≤ PIPE_BUF (4096 bytes).

Byte  0      : type  — 0x41 ('A') = IPv4 allowed,    0x36 ('6') = IPv6 allowed
                       0x52 ('R') = IPv4 refused,     0x72 ('r') = IPv6 refused
                       0x44 ('D') = IPv4 dns-only,    0x64 ('d') = IPv6 dns-only
Bytes 1-2    : DNS txid (big-endian uint16)
Bytes 3-18   : source IP — 16 bytes
                 IPv4 : 4 bytes address + 12 zero bytes (padding)
                 IPv6 : 16 bytes address (complete, no truncation)
Bytes 19-20  : source port (big-endian uint16)
Bytes 21-26  : source MAC (6 bytes, zeroed if unavailable)
Bytes 27-42  : resolver IP — 16 bytes
                 IPv4 : 4 bytes address + 12 zero bytes (padding)
                 IPv6 : 16 bytes address (complete, no truncation)

response maintains a table pending[txid:ip:port:resolver_ip] = {expire, refused, dnsonly} (TTL 5s). refused=true means question determined the query must be blocked; response transforms the upstream response into a REFUSED reply instead of patching TTL. dnsonly=true means question allowed the query but without nft IP injection (e.g. captive portal probes): response patches TTL + EDE but does not call nft add element. Purge is lazy: an expired entry is removed at lookup time, without a separate timer.

Pipe learn (22 octets)

The learn pipe carries MAC/IP associations, written by worker_questions, worker_arp_sniffer and worker_auth_queue, and read by mac_learner.

Bytes 0-15 : IP address — 16 bytes
               IPv4 : 4 bytes address + 12 zero bytes (left-padded)
               IPv6 : 16 bytes address (complete)
Bytes 16-21: source MAC (6 bytes)

Pipes events, nft et ack_<i>

Three further pipes, all created in main.moon before fork():

  • events : DNS events from worker_questionsworker_events (aggregation/persistence).
  • nft : serialized nftables insertion commands from worker_responses, worker_tls, worker_sip and worker_dohworker_nft.
  • ack_<i> : one per producer worker; worker_nft writes a 1-byte ACK after each batch flush so the producer can return its verdict once the set element is live.

TTL Patch

Each allowed DNS response is modified before being returned to the client:

  1. All Resource Record TTLs are rewritten to 60 seconds
  2. An EDNS OPT option EDE code 0 "Other" with extra-text "Custos vigilat." is appended to the response's OPT RR, signalling that TTL was clamped
  3. L4 checksum is recalculated (UDP or TCP, IPv4/IPv6 pseudo-header)
  4. IPv4 header checksum is recalculated when applicable
  5. NF_ACCEPT verdict is set with modified payload via nfq_set_verdict(qh, pkt_id, NF_ACCEPT, len, patched_ptr)

Each blocked DNS response (where question sent refused=true) is replaced by a REFUSED reply with EDE code 15 "Filtered" and extra-text "Custos vigilat.", reconstructed from the upstream server's TCP/UDP framing (so no raw-socket spoofing is needed).

For multi-segment TCP DNS responses, response buffers segments, patches the fully assembled DNS payload once complete, then reinjects a single coalesced PSH|ACK segment (with corrected checksums and initial sequence number).

The goal is to force clients to re-validate resolution every 60 seconds, ensuring IPs authorized in nft sets (2-minute timeout) remain valid as long as the client actively resolves the name.


Authentication

CustosVirginum includes an HTTPS authentication server that maps LAN client IPs to user accounts. The from_user filter condition allows rules such as "only user alice can reach github.com".

Process model

The AUTH worker is forked by the supervisor alongside the DNS workers, the captive portal worker, and several auxiliary workers:

main (supervisor)
├── mac_learner          (table IP→MAC, socket Unix)
├── worker_arp_sniffer   (ARP/NDP passif → pipe learn)
├── worker_auth_queue    (NFQUEUE port 33443 → pipe learn)
├── worker_events        (agrégation des événements DNS)
├── worker_questions ×N (DNS questions → pipes question_response/learn/events)
├── worker_responses ×N (DNS réponses → pipe nft)
├── worker_nft           (sérialise les insertions nft + ACK par worker)
├── worker_captive   ×N (TCP/80 SYN → AF_PACKET 302)
├── worker_reject    ×N (forge RST/ICMP)
├── worker_tls           (SNI TLS/QUIC 443, optionnel → pipe nft)
├── worker_sip           (SIP/STUN, optionnel → pipe nft)
├── worker_doh           (serveur DoH HTTPS 8443, optionnel → pipe nft)
└── worker AUTH          (HTTPS WolfSSL, port 33443 : portail captif + admin /admin/*)

Sessions are shared via a Lua-evaluable file (/tmp/sessions.lua). question/response workers reload it every 5 seconds (TTL cache). No inter-process socket is needed.

TLS certificate

The AUTH worker generates self-signed certificates dynamically via px5g (WolfSSL-based) with an LRU/TTL cache (100 slots, 24h). Certificates are generated on-demand based on the SNI (Server Name Indication) from the TLS ClientHello.

To use your own static certificate, configure auth.cert and auth.key in /etc/custos/config.moon:

auth:
  cert: "/etc/custos/auth.crt"
  key:  "/etc/custos/auth.key"
  secrets: "/etc/custos/secrets"
  session_ttl: 0            -- seconds (default: 0 = no absolute expiry)

Secrets file

Each line holds one credential in the format:

user:pbkdf2-sha256:<iterations>:<salt_hex>:<hash_hex>

Generate an entry with:

make make-secret USER=alice PASS=hunter2
# → append the printed line to cfg/secrets

See cfg/secrets.sample for a full example.

Logging in

Navigate to https://<router>:33443/ in a browser (accept the self-signed cert warning). After a successful login the client MAC address is recorded in the session store as the primary identifier. This MAC-primary architecture allows seamless cross-family tracking (IPv4/IPv6) and handles IP changes gracefully. Sessions expire after idle_timeout seconds without heartbeat, or on explicit logout. session_ttl is optional; 0 disables absolute expiry.

Using from_user in rules

filter:
  rules:
    {
      description: "alice-only"
      conditions:
        { from_user: "alice" }
        { to_domains: {"github.com", "pypi.org"} }
      actions: {"allow"}
    }

Multiple users can be listed (logical OR):

      conditions:
        { from_users: {"alice", "bob"} }

Users from a text file (lists_dir/user/admins.txt, one username per line):

      conditions:
        { from_user_list: "admins" }

Multiple files (OR):

      conditions:
        { from_user_lists: {"admins", "vip"} }

Captive portal

A dedicated captive worker intercepts TCP/80 SYN from unauthenticated clients via NFQUEUE 2 and responds directly with an HTTP 302 redirect to the HTTPS portal (port 33443), without going through the kernel proxy. Once authenticated, the client IP is added to authenticated_ips and TCP/80 SYN are no longer intercepted.

The dnsonly condition detects captive portal probes (connectivitycheck, generate_204, etc.) and allows them to pass at the DNS level without injecting IPs into nft sets — the client can thus resolve domain names without accessing target servers before being authenticated:

{
  description: "Captive portal probes"
  actions: {"dnsonly"}
  conditions:
    { to_domains: {
      "connectivitycheck.gstatic.com"
      "captive.apple.com"
      "msftconnecttest.com"
    } }
}

Default Built-in Probes (NCSI/MSFT, Apple, Google…)

The default rules (filter.default_rules, see src/config.moon) already include the canonical set of connectivity probes inline (via to_domains), so they are functional immediately after installation, without depending on an external list:

  • Google/Android: connectivitycheck.gstatic.com, connectivitycheck.android.com, connectivitycheck.google.com, clients3.google.com
  • Apple: captive.apple.com
  • Microsoft NCSI: msftconnecttest.com, msftncsi.com
  • Firefox: detectportal.firefox.com — Ubuntu: connectivity-check.ubuntu.com — KDE: networkcheck.kde.org

Suffix matching covers all subdomains: msftncsi.com covers the DNS probe dns.msftncsi.com (NCSI checks that it resolves to 131.107.255.255; dnsonly leaves the upstream response intact) and the legacy HTTP probe www.msftncsi.com; msftconnecttest.com covers www. and ipv6.msftconnecttest.com (active HTTP probe Windows 10/11).

Two default rules govern these domains: allow for authenticated users (from_user: "_any", firewall opening → the probe succeeds, no portal) and dnsonly for others (DNS resolution only → the HTTP probe is intercepted by the captive worker and redirected to the portal).

These two rules are governed by the filter.captive_portal option (default true). Setting it to false removes them (the Firefox DoH canary remains active):

filter: { captive_portal: false }

SafeSearch (réécriture CNAME)

L'option filter.safe_search (défaut true) ajoute des règles par défaut qui réécrivent la réponse DNS des moteurs de recherche vers leur variante « safe » via l'action générique cname : Google → forcesafesearch.google.com, YouTube → restrictmoderate.youtube.com (ou restrict.youtube.com), Bing → strict.bing.com, DuckDuckGo → safe.duckduckgo.com. Le filtre répond par un CNAME et, quand la résolution de la cible aboutit côté résolveur upstream, enrichit la réponse avec des RR A/AAAA de cette cible (TTL borné). Le mécanisme passe par le callback on_response (worker responses et worker doh) : il couvre le DNS clair UDP et TCP ainsi que le DoH transitant par le worker doh. Mode YouTube réglable (filter.youtube_restrict: "strict"/"moderate"/false).

Important : l'action cname ne décide pas l'autorisation. Elle modifie la question/réponse DNS en effet de bord (on_response) ; le verdict final ALLOW/DENY reste déterminé uniquement par les autres actions de règle (allow, deny, dnsonly, etc.).

filter: { safe_search: false }          -- désactiver
filter: { youtube_restrict: "strict" }  -- YouTube en mode strict

L'action cname étant générique, elle s'utilise aussi dans filter.rules pour réécrire un domaine arbitraire : { actions: {"cname"}, conditions: { to_domain: "exemple.fr" }, cname: "cible.exemple.fr" }.

User Conditions

from_user, from_users, from_user_list, from_user_lists allow associating rules with authenticated accounts:

{
  description: "alice-only"
  conditions:
    { from_user: "alice" }
    { to_domainlist: "toulouse/adult" }
  actions: {"allow"}
}

Multiple users (logical OR):

  conditions:
    { from_users: {"alice", "bob"} }

From a text file ({lists_dir}/user/admins.txt):

  conditions:
    { from_user_list: "admins" }

Known Limitations

  • DoH (DNS-over-HTTPS): partially covered. CustosVirginum can run its own DoH resolver (worker_doh, port 8443) and apply the same filtering policy; it also ships a default rule answering NXDOMAIN to Firefox's canary domain to disable its auto-DoH. Arbitrary third-party DoH endpoints over port 443 are constrained via the SNI verdict mechanism (worker_tls, cfg.sni) rather than DNS.
  • DoT (DNS-over-TLS, port 853): not covered.
  • Scaling: each worker processes its NFQUEUE socket single-threadedly by design (share-nothing architecture). libnfq does support out-of-order verdicts (each verdict references its packet by packet_id), but intra-queue parallelism would require shared-state synchronisation in workers that maintain flow context (pending table, TCP reassembly). Horizontal scaling via multiple queue numbers (QUEUE_QUESTIONS="0,1,2") with nftables hash distribution (queue num 0-2) is the correct approach.
  • MAC spoofing: mac4_allowed/mac6_allowed rely on the MAC address reported by nfq_get_packet_hw. On a bridge, this is the L2 source MAC and can be spoofed by a LAN client.

nft Ruleset

The single file nft-rules/dns-filter-bridge.nft is a ruleset for bridge mode.

How it works

  • DNS (UDP/TCP port 53) from LAN → NFQUEUE_QUESTIONS (worker_questions)
  • DNS responses (sport 53) to LAN → NFQUEUE_RESPONSES (worker_responses)
  • TCP/80 SYN from LAN → NFQUEUE_CAPTIVE (worker_captive)
  • TCP/33443 → NFQUEUE_AUTH (worker_auth_queue)
  • TCP/UDP/443 → NFQUEUE_SNI (worker_tls, optional)
  • SIP/STUN → NFQUEUE_SIP (worker_sip, optional)
  • Rate-limited reject traffic → NFQUEUE_REJECT (worker_reject)
  • Queue numbers are configurable (config section nfqueue, or UCI: QUEUE_QUESTIONS, QUEUE_RESPONSES, QUEUE_CAPTIVE, QUEUE_AUTH, QUEUE_SNI, QUEUE_SIP, QUEUE_REJECT). Defaults: questions 0-1, responses 4, captive 20, reject 10-11, auth 5, sni 6, sip 12. Ranges like "0,2,5-7" spawn one worker per queue number.
  • LuaJIT decides ACCEPT, REFUSED, or DNSONLY; populates ip4_allowed/ip6_allowed on success
  • Clients in authenticated_ips bypass TCP/80 interception (QUEUE_CAPTIVE)
  • All forwarded traffic matching a set entry → ACCEPT; rest → DROP/REJECT

Sets nftables

Set Type Rôle
ip4_allowed ipv4_addr . ipv4_addr Paire (src IP client, IPv4 dest) autorisée après résolution DNS
ip6_allowed ipv6_addr . ipv6_addr Paire (src IPv6 client, IPv6 dest) autorisée après résolution DNS
mac4_allowed ether_addr . ipv4_addr Paire (MAC client, IPv4 dest) autorisée (règles liées à une MAC)
mac6_allowed ether_addr . ipv6_addr Paire (MAC client, IPv6 dest) autorisée (règles liées à une MAC)
authenticated_macs ether_addr MACs clientes authentifiées (bypass intercept TCP/80 captive)
authenticated_ips ipv4_addr IPs clientes IPv4 authentifiées (bypass intercept TCP/80 captive)
authenticated_ips6 ipv6_addr IPs clientes IPv6 authentifiées (bypass intercept TCP/80 captive)
ip4_dest_whitelist ipv4_addr Destinations IPv4 toujours autorisées (bypass DNS, rechargement SIGHUP)
ip6_dest_whitelist ipv6_addr Destinations IPv6 toujours autorisées (bypass DNS, rechargement SIGHUP)

Prerequisites

Sur OpenWrt, les règles nft sont appliquées automatiquement par le service au démarrage. Pour appliquer manuellement :

nft -f nft-rules/dns-filter-bridge.nft

DHCP / SLAAC

The ruleset explicitly passes bootstrap traffic that cannot be tracked by conntrack and must therefore bypass the policy drop:

Traffic Direction Rule
DHCPv4 (UDP 67/68) FORWARD udp dport { 67, 68 } accept
DHCPv4 server on filter machine INPUT udp dport 67 accept
DHCPv6 (UDP 546/547) FORWARD udp dport { 546, 547 } accept
DHCPv6 server on filter machine INPUT udp dport 547 accept
SLAAC Router Advertisement from upstream router FORWARD icmpv6 type nd-router-advert accept

Router Advertisements emitted by the filter machine itself (radvd, WireGuard relay…) exit via the OUTPUT chain whose policy accept already covers them.

IPv6 / ICMPv6

The IPv6 FORWARD chain explicitly passes NDP messages (neighbor-solicit, neighbor-advert, router-solicit, router-advert) and ICMPv6 echo — required for IPv6 connectivity.


Destination Whitelist (Bypass DNS Analysis)

For networks that should bypass DNS analysis entirely (e.g., servers accessible from outside), configure a destination whitelist via UCI:

# On OpenWrt router
uci add_list custos.main.dest_whitelist '10.0.0.0/24'
uci add_list custos.main.dest_whitelist '2001:db8::/32'
uci commit custos
/etc/init.d/custos reload

Traffic to these CIDRs is allowed without DNS resolution. The ip4_dest_whitelist and ip6_dest_whitelist nftables sets are checked before DNS NFQUEUE, enabling direct access.

The whitelist can also be configured in /etc/custos/config.moon:

filter:
  dest_whitelist: {
    "10.0.0.0/24"
    "2001:db8::/32"
  }

Web Administration Interface

The AUTH worker serves an administration interface under /admin/* on the same HTTPS port as the captive portal (33443). Access is protected by an authenticated session and restricted to accounts listed in auth.admin_users (if the list is empty, auth.admin_allow_all_when_empty allows any authenticated user).

The interface allows, without CLI:

  • editing sections of config.moon (re-read/re-written in MoonScript via webui/serializer);
  • setting general filter options via Filter — General (SafeSearch, YouTube Restricted, allow_localnets, captive portal, allowed domains…);
  • managing filtering rules (add, edit, delete, reorder);
  • managing named dictionaries (nets, macs, users, times) and lists;
  • viewing the dashboard (status, events) and triggering a reload (SIGHUP).
https://<router>:33443/admin/

DoH (serveur DNS-over-HTTPS intégré)

worker_doh peut exposer un résolveur DoH (RFC 8484) en HTTPS sur le port doh.port (défaut 8443). Les requêtes sont résolues auprès d'un upstream DNS (doh.upstream_ipv4 / doh.upstream_ipv6, choix selon doh.prefer_ipv6), puis passent par le même moteur de filtrage que les requêtes DNS classiques : les paires autorisées sont injectées dans les sets nft.

doh:
  enabled: true
  port: 8443
  upstream_ipv4: "1.1.1.3"
  upstream_ipv6: "2606:4700:4700::1113"
  prefer_ipv6: true
  -- cert/key optionnels (sinon certificat px5g dynamique)

Une règle par défaut répond NXDOMAIN au domaine canari de Firefox (use-application-dns.net) pour désactiver son auto-DoH et forcer le passage par le résolveur filtré.


Filtrage SNI (TLS / QUIC)

worker_tls (optionnel, nfqueue.sni) intercepte les paquets TCP/443 (ClientHello TLS) et UDP/443 (QUIC Initial), extrait le SNI via ipparse, puis applique filter.decide sur le nom extrait. En mode sni.mode = "strict-443" :

  • allow → la paire client→destination est ajoutée aux sets nft ;
  • deny / SNI absent → le paquet est rejeté (NF_DROP).
sni: {
  enabled: true
  mode: "strict-443"     -- ou "permissive" pour journaliser sans bloquer
  protocols: "both"       -- "tls" | "quic" | "both"
  nft_failure_policy: "fail-closed"
}

Cela complète le filtrage DNS pour les clients qui contournent la résolution (IP en dur, DoH tiers).


SIP / VoIP

worker_sip (optionnel, nfqueue.sip) parse la signalisation SIP/SDP et STUN/ICE (src/sip/parser.moon), extrait les IP de médias (RTP/RTCP) et l'IP du proxy, puis les whiteliste dynamiquement dans des sets nft par règle (TTL nft.sip_session_ttl). Les ports RTP à exclure sont configurables via rtp.excluded_ports.


Multi-router Configuration Synchronization

To manage multiple filters from a central git repository:

# On the dev machine: initialize a device in pull mode (cron */15)
make sync-init HOST=root@<router> REPO=https://git.example.com/custos-configs

# Initialize a reference filter authorized to publish (push)
make sync-push-init HOST=root@<router> REPO=https://git.example.com/custos-configs

sync/apply.moon merges base/config.moon with devices/<hostname>/config.moon from the repository and writes /etc/custos/config.moon (option --reload to send SIGHUP). custos-sync.sh (pull) and custos-sync-push.sh (push) read CUSTOS_CONFIG_REPO from /etc/custos/sync.conf.

A local redbean UI (.init.moon, make redbean-ui) also allows installing, uninstalling, and synchronizing a router without CLI; see doc/CHEATSHEET.md § "Installation UI (redbean)".


Ask DeepWiki

About

Custos Virginum : filtre réseau basé sur nfqueue + luajit

Topics

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors