crenel — the deliberate opening in the wall

Every edge in atomic agreement. Verified.

Crenel is a zero-dependency CLI that keeps a split-horizon homelab honest. One command exposes a service locally or publicly — across Caddy, AdGuard, and Cloudflare in one atomic step — then re-reads the live edge to prove the change actually landed. Nothing is reachable unless you explicitly opened it, and audit/drift can prove that at any moment.

$ crenel expose photos --to immich:2283 \
    --auth authelia
# → proxy route on every edge, internal DNS
#   rewrite, public DNS record, auth gate —
#   previewed, applied atomically, then
#   VERIFIED by read-back.
$ crenel status  # exposed RIGHT NOW (live)
$ crenel drift   # non-zero if reality disagrees
Terminal recording: crenel expose, verify by read-back, drift check, unexpose
A real recording: expose → read-back ✓ on four systems → drift clean → unexpose.

Is Crenel for you?

Probably not, if…
  • You just want to expose services to the internet. Use Pangolin or Cloudflare Tunnel — they're excellent, and Crenel doesn't compete with them.
  • Every device you care about can run a mesh client. Tailscale Serve/Funnel or NetBird's service toggle already give you one-switch exposure. Take the simpler stack.
Built for

Split-horizon on your own domain: clientless LAN devices (TVs, IoT, guests) resolving app.your.domain to a local proxy at LAN speed, the same name resolving publicly through a hardened edge, AdGuard or Pi-hole doing your filtering — and you owning every hop.

One logical change there touches four or five systems. Keeping them agreeing by hand is exactly where silent drift and accidental exposure live. Crenel makes it one verified command.

Remove Crenel tomorrow and your infrastructure keeps running untouched — it's a control plane over the tools you already trust, not another proxy or tunnel.

By handIaCTunnel applianceMesh servicesCrenel
Reverse-proxy routesmanualreplaces yoursits owndrives your proxy
Public DNS recordsmanualwildcard oncemanual CNAME
Split-horizon LAN DNS
clientless devices
manualroll-your-ownmesh-onlyAdGuard / Pi-hole
Local ↔ public toggle per servicepartialmesh = "local"
Verified against live statestate file driftsread-back, every write
Default-deny proven, not assumedaudit / drift
Keeps your existing stackgreenfieldreplaces itremovable anytime

What works with what

Vendor-agnostic architecture (ports-and-adapters; a new driver is a contribution, not a rewrite) — but honesty about the implemented surface:

Caddy first-class

read
✓ admin API
write
✓ + durable persist
verify
✓ read-back

Traefik file driver

read
✓ file
write
✓ file only
verify
⚠ probe opt-in
write refused without a probe URL unless --allow-unverified

nginx file driver

read
✓ file
write
✓ file only
verify
⚠ same as Traefik
managed-block re-render fidelity documented

Cloudflare DNS public

read
write
✓ surgical / whole-zone
verify
ownership marker managed-by:crenel — never touches records it didn't create

AdGuard Home internal

read
✓ rewrites
write
✓ dual-instance
verify
✓ presence
value-drift detection deliberately opt-out (marker-less API)

Pi-hole v6 internal

read
✓ host entries
write
✓ dual-instance
verify
✓ presence
IP targets only; wildcards refused (they live in dnsmasq confs, outside the API)

Tailscale read

read
✓ serve.json
write
🚧 planned
verify

NetBird read

read
✓ read-only
write
— by design
verify
mesh grants surfaced, never driven
Forward-auth, by reference

An exposure carries auth: authelia and Crenel renders the per-edge reference — you own the auth config. Publishing a host public with no auth is refused unless you say --auth none out loud.

Multi-zone

One edge can serve hosts under several apex zones — zones: ["a.example", "b.example"], one block per resolver box. Every host routes only to providers whose zone covers it; an ambiguous bare name is refused with the candidate FQDNs.

Residency-aware

A host that doesn't live at home gets a residency class (--residency vps) so each internal resolver answers with its own vantage-correct address — a class no resolver covers is refused at plan time, never silently defaulted.

Audit any edge in 30 seconds

Point crenel audit at the edge you already run — no settings file, read-only, nothing written. Say the boundary out loud (--assume-public-boundary for an internet-facing edge, --internal for a LAN-only one) and crenel reports what is exposed, whether default-deny is structural, and where auth is missing:

$ crenel audit ./Caddyfile --assume-public-boundary
$ crenel audit http://localhost:2019 --assume-public-boundary
$ crenel audit /path/to/npm/data/nginx --assume-public-boundary
$ crenel audit http://traefik:8080 --assume-public-boundary

A Caddyfile on disk, a running Caddy's admin API, an Nginx Proxy Manager data dir, or Traefik's API (Pangolin and docker-labels setups included) — generator detected, routes enumerated, writes to foreign generators refused. Only the URL you paste is ever contacted; anything beyond it is opt-in via --probe. Foreign edges exit 0 when otherwise clean — cron it.

Proven, not promised

715test functions, race-clean
0external dependencies
4legs verified per expose
100%live trials reverted byte-for-byte

The claims that matter were exercised against real production edges: cross-edge atomic rollback, durable persist through container restarts, surgical writes on a shared production zone, a live Pi-hole v6 cycle — plus an independent third-party audit with no critical or high findings. The first live cross-chain write aborted atomically with zero changes applied when it surfaced a bug the fakes couldn't catch — which is the whole argument for read-back verification. Trial records on GitHub.

Install
# stdlib only — zero deps
$ go install github.com/crenelhq/crenel/cmd/crenel@latest

Or a static binary for linux/darwin (amd64/arm64) from Releases — verify by SHA256.

An agent can drive it

crenel mcp speaks the Model Context Protocol over stdio — read-only by construction (a mutating call is unrepresentable, not merely refused); opt-in --write adds a two-phase gated plan/apply pair with every CLI guardrail intact. docs/MCP.md.

Documented limits (honest)

Marker-less AdGuard/Pi-hole value drift isn't detected (presence is verified; value-checking would cry wolf); path-granular routing is detected and declared, not modeled; Tailscale serve write support is unbuilt. Full current state.

github.com/crenelhq/crenel LICENSE Apache-2.0 DEFAULT-DENY DRIFT none

A crenel (/ˈkrɛn.əl/, rhymes with kennel) is the gap in a castle's battlement — the deliberate opening you choose to expose. The wall is solid by default; you cut the gaps.