Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
.claude/
.shen-kernel-cache.bin
.shen-kernel-cache.bin.tmp
.shen-kernel-cache*.bin
.shen-kernel-cache*.bin.tmp
build/shen-bundle.lua
.fasl-test/
luacov.stats.out
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,11 @@ Two caches make warm starts near-instant (both content-keyed, both safe to
delete at any time):

* **Kernel bytecode cache** — the compiled kernel is `string.dump`ed after the
first boot (`.shen-kernel-cache.bin`); warm boots load it in **~30 ms**
instead of recompiling (~1 s).
first boot (`.shen-kernel-cache.<build>.bin`, one file per exact Lua build,
since bytecode is not portable across builds — so e.g. your `luajit` and an
embedded OpenResty/Envoy LuaJIT each keep their own warm cache instead of
invalidating each other's); warm boots load it in **~30 ms** instead of
recompiling (~1 s).
* **User fasl cache** — `(load "prog.shen")` records its compiled chunks and
replays them on later runs, skipping the reader, macroexpansion *and
typechecking* (SBCL-fasl semantics: it typechecked when it compiled).
Expand Down Expand Up @@ -309,6 +312,7 @@ browser. One `rules.shen`, two runtimes, no client/server drift. See its
| [`examples/pcr/`](examples/pcr/) | **proof-carrying requests over live facts**: the client carries a proof term, the OpenResty gate *checks* it — never searches — against a versioned fact store consulted at proof time, so revoking one fact makes the same proof bytes fail on the next request while delegation chains stay composable and every allow logs its justification ([README](examples/pcr/README.md)) |
| [`examples/openresty/`](examples/openresty/) | a **complete web app in Shen on OpenResty** (nginx + LuaJIT): typed request validators + a Shen router behind a JSON API, with a front end that runs the **same** typed rules in the browser — Ratatoskr-shaken and ShenScript-compiled to a ~140 KB module. One `rules.shen`, validated client- and server-side. Runs standalone (`luajit examples/openresty/selftest.lua`) or under `openresty` ([README](examples/openresty/README.md)) |
| [`examples/openresty-authz/`](examples/openresty-authz/) | durable multi-tenant **authorization**: the policy as a Prolog proof chain (`token → user → tenant → resource`), a typed `decision` witness that gates every response, and an event-sourced store (file + `lua-resty-lmdb`) whose append-only log makes decisions durable and auditable ([README](examples/openresty-authz/README.md)) |
| [`examples/envoy/`](examples/envoy/) | **Shen at the edge**: Envoy fronting both apps above — `ext_authz` runs every request through the authz proof chain (edge decisions durably audited), and an Envoy **Lua filter** runs the same typed `rules.shen` inside the proxy, so malformed requests get their typed 400 before costing an upstream hop ([README](examples/envoy/README.md)) |

## Certification / Testing

Expand Down
47 changes: 46 additions & 1 deletion boot.lua
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,35 @@ do
pcall(jit.opt.start,
"sizemcode=2048", "maxmcode=131072", "maxtrace=8000", "maxside=400")
end
-- Some hosts leave the JIT nominally ON but deny the process executable
-- trace memory (issue #55): macOS hardened-runtime binaries embedding
-- LuaJIT (e.g. Envoy's Lua filter — no JIT entitlement) report
-- jit.status() == true, yet every trace attempt aborts with "failed to
-- allocate mcode memory" and is re-attempted on the next hot path.
-- Measured in Envoy 1.39 on an arm64 Mac: a hot loop ran ~550x slower
-- than plain interpretation and kernel boot took 40-66 s (vs ~3 s
-- interpreted). Detect it directly — compile one throwaway hot loop and
-- watch for a trace "stop" event — and fall back to the interpreter.
-- SHEN_JIT=on skips the probe (explicit opt-in for verified hosts).
if os.getenv("SHEN_JIT") ~= "on" and jit.status and jit.status()
and jit.attach then
local compiled = false
local watcher = function(what)
if what == "stop" then compiled = true end
end
if pcall(jit.attach, watcher, "trace") then
local mk = loadstring or load
local probe = mk("local s = 0 for i = 1, 400 do s = s + i end return s")
if probe then for _ = 1, 3 do probe() end end
pcall(jit.attach, watcher) -- detach
if not compiled then
disable_jit()
io.stderr:write("shen-lua: the JIT reports enabled but compiled no"
.. " trace (executable memory denied? hardened host?); running"
.. " interpreted. Set SHEN_JIT=on to skip this probe.\n")
end
end
end
end
end
-- GC tuning. Compiled-KL workloads are cons-churn-heavy (jit.p on urdr's
Expand Down Expand Up @@ -211,12 +240,21 @@ local function fnv1a(s, h)
return h
end

-- Each Lua build gets its own default cache FILE, not just its own key:
-- bytecode is only portable within the exact build, and two hosts sharing one
-- path — your `luajit` and an embedded LuaJIT with a different jit.version
-- (OpenResty's, Envoy's) — would see a key mismatch on every alternation and
-- invalidate + rewrite each other's cache, recompiling the kernel every time.
-- The filename suffix is a hash of the same version/arch fingerprint that
-- cache_key() folds into the content key.
local function cache_path()
if not bit then return nil end -- PUC Lua: no `bit` -> no cache keys
local p = os.getenv("SHEN_KERNEL_CACHE")
if p == "off" or p == "0" then return nil end
if p and p ~= "" then return p end
return ".shen-kernel-cache.bin"
return ".shen-kernel-cache."
.. bit.tohex(fnv1a(jit and (jit.version .. jit.arch) or _VERSION))
.. ".bin"
end

local function read_file(path)
Expand Down Expand Up @@ -316,6 +354,13 @@ local function write_cache(path, key, chunks, arity)
fh:write(table.concat(parts)); fh:close()
os.remove(path)
os.rename(tmp, path)
-- Writing a per-build default cache obsoletes the old shared-path file from
-- pre-per-build versions (it would sit stale forever otherwise). Only the
-- default path triggers this — an explicit SHEN_KERNEL_CACHE never does.
if path:match("^%.shen%-kernel%-cache%.%x+%.bin$") then
os.remove(".shen-kernel-cache.bin")
os.remove(".shen-kernel-cache.bin.tmp")
end
end

-- Parse a write_cache blob. key == nil skips the key check (used for the
Expand Down
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ has a `selftest.lua` that runs off-nginx):
| [`pcr/`](pcr/) | **proof-carrying requests over live facts**: the client attaches a proof term, the OpenResty gate *checks* it — never searches — against a versioned fact store consulted at proof time, so revoking one fact makes the same proof bytes fail on the next request while delegation chains stay composable and every allow logs its full justification. `luajit examples/pcr/selftest.lua` |
| [`openresty/`](openresty/) | a complete web app — typed Shen validators + a Shen router on OpenResty (nginx + LuaJIT), with a front end that runs the **same** rules in the browser (Ratatoskr-shaken, ShenScript-compiled). Runs standalone via `luajit examples/openresty/selftest.lua`; see [its README](openresty/README.md) to serve it. |
| [`openresty-authz/`](openresty-authz/) | durable multi-tenant **authorization**: the policy as a Prolog proof chain (`token → user → tenant → resource`), a typed `decision` witness that gates every response, and an event-sourced store (file + `lua-resty-lmdb`) whose append-only log makes decisions durable and auditable. Runs standalone via `luajit examples/openresty-authz/selftest.lua`; see [its README](openresty-authz/README.md). |
| [`envoy/`](envoy/) | **Shen at the edge**: Envoy in front of both apps above — its `ext_authz` filter sends every request through the authz app's proof chain (edge decisions land in the same durable audit log), and an Envoy **Lua filter** runs the guestbook's typed `rules.shen` *inside the proxy* (LuaJIT), rejecting malformed bodies at the edge with the origin's exact error strings. One typed rule file, four hosts. Runs standalone via `luajit examples/envoy/selftest.lua`; see [its README](envoy/README.md). |

The last three (`configc/`, `policy/`, `crdt/`) are a themed trio: each extracts
a correctness-critical kernel into one typed, portable Shen file that runs
Expand Down
196 changes: 196 additions & 0 deletions examples/envoy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
# Shen at the edge: Envoy in front of two Shen services

The third piece of the web trilogy. [`examples/openresty`](../openresty) is a
Shen web app; [`examples/openresty-authz`](../openresty-authz) is Shen
authorization as a proof chain. This example puts **[Envoy](https://www.envoyproxy.io)**
— the standard edge/mesh proxy — in front of both, and uses each of Envoy's two
integration seams for exactly what it is good at:

1. **`ext_authz` → the authz app.** Envoy sends every API request (method +
path + `authorization` header, no body) to the authz service *before*
routing it. The Prolog proof chain decides; a denial returns the discharge
report to the client; and **every edge decision lands in the same durable
audit log** as a direct API call. No authorization code lives in the proxy.

2. **A Lua filter → `rules.shen` inside the proxy.** Envoy's Lua filter embeds
LuaJIT — shen-lua's primary host — so the guestbook's typed field rules run
*in the proxy itself*: a malformed POST gets its 400 **at the edge**, with
the same typed error strings the browser and the origin produce, before it
costs an upstream hop.

Which makes it one `rules.shen`, enforced on **four hosts** from one typed
source: the browser (ShenScript, Ratatoskr-shaken), the Envoy edge (shen-lua on
Envoy's LuaJIT), the origin (shen-lua on OpenResty), and plain `luajit` in the
selftests. Proved sound by the sequent-calculus typechecker wherever shen-lua
loads it.

```
examples/envoy/
envoy.yaml the edge: ext_authz (→ authz app) + the Lua filter + routing
filter.lua Shen in the proxy — boots shen-lua once per worker thread,
loads ../openresty/rules.shen under (tc +), 400s bad bodies
authz.conf the authz app as ext_authz backend: same app as
examples/openresty-authz, on :8081, seeding a "guestbook"
resource (run with -p examples/envoy so its log lives here)
selftest.lua the whole three-layer pipeline under plain luajit — no Envoy,
no nginx (a faithful fake of Envoy's request_handle drives the
real filter.lua; the real authz app decides; only the
guestbook upstream is a stand-in)
```

## Try it without Envoy

```sh
luajit examples/envoy/selftest.lua
```

It runs requests through the chain in Envoy's exact filter order — ext_authz,
then the Lua filter, then the upstream — and checks *which layer* answers:

```
== gate 1: the proof chain holds the edge (ext_authz) ==
no token -> 403 @ ext_authz (unauthenticated: ...)
carol: not a member -> 403 @ ext_authz (not a member of tenant acme)
unmapped path fails closed -> 403 @ ext_authz (unknown resource)
bob (viewer) may read -> 200 @ upstream
bob (viewer) may NOT post -> 403 @ ext_authz (requires the editor role)
== gate 2: typed rules.shen at the edge (the Lua filter) ==
alice posts, missing name -> 400 @ lua-filter (name: is required)
...
== every edge decision is in the durable audit log ==
#9 carol read guestbook deny not a member of tenant acme
...
OK — all cases passed (ext_authz + Lua filter + upstream)
```

## Run it for real

Three processes, all from the repo root (`brew install envoy openresty` or your
platform's equivalents):

```sh
# 1. the guestbook origin, :8080 — the openresty example, unchanged
mkdir -p examples/openresty/logs
openresty -p "$PWD/examples/openresty" -c nginx.conf

# 2. the authz app as ext_authz backend, :8081
mkdir -p examples/envoy/logs
openresty -p "$PWD/examples/envoy" -c authz.conf

# 3. Envoy at the edge, :10000 (run from the repo root — filter.lua and the
# bytecode cache resolve against the cwd)
envoy -c examples/envoy/envoy.yaml --concurrency 2
```

Then drive the edge:

```sh
# no token → the proof chain denies at the edge; the guestbook never sees it
curl -s localhost:10000/api/messages
# carol has no membership → denial WITH the discharge report, from ext_authz
curl -s localhost:10000/api/messages -H 'authorization: Bearer tok-carol'
# bob is a viewer: reads pass, posts need the editor role
curl -s localhost:10000/api/messages -H 'authorization: Bearer tok-bob'
curl -s localhost:10000/api/messages -H 'authorization: Bearer tok-bob' \
-d '{"name":"bob","message":"hi"}'
# alice (editor) is authorized — but the EDGE now runs the typed rules:
# this 400 comes from Envoy's Lua filter, not the origin
curl -s localhost:10000/api/messages -H 'authorization: Bearer tok-alice' \
-d '{"message":"no name"}'
# a valid entry survives both gates and reaches the guestbook
curl -s localhost:10000/api/messages -H 'authorization: Bearer tok-alice' \
-d '{"name":"ada","message":"through the edge"}'
# every one of those edge decisions is durably audited in the authz app
curl -s localhost:8081/api/admin/audit -d '{"token":"tok-admin"}'
```

## How it fits together

```
┌────────────── Envoy :10000 ──────────────┐
client ─ request ──► │ 1 ext_authz ────────────────────────────┼──► authz app :8081 (OpenResty)
│ GET /authz/api/messages │ (route "CHECK" Path ...)
│ + authorization header, no body │ the Prolog proof chain over
│ 200 = allow; else the denial body │ durable facts; every decision
│ (the discharge report) goes to │ appended to the audit log
│ the client verbatim │
│ 2 lua filter: filter.lua │
│ rules.shen, typed, on Envoy's │
│ LuaJIT → 400 at the edge │
│ 3 router │
└────────────────────┬─────────────────────┘
guestbook :8080 (OpenResty)
re-runs the SAME rules.shen as the
authoritative check (defense in depth)
```

## What Envoy changes, coming from OpenResty

Both embed LuaJIT, but with opposite philosophies — and the split above falls
straight out of the differences:

- **Lua's role.** In OpenResty, Lua is the *application platform*: cosockets,
`lua_shared_dict`, timers, `init_worker`. In Envoy, Lua is a *scripting
hook* (`envoy_on_request`/`envoy_on_response`) for inspecting and mutating
traffic; its only sanctioned I/O is `handle:httpCall()` to a configured
cluster.
- **No shared state.** Every Envoy worker *thread* has its own Lua state —
"there is no truly global data." No shared dict means no in-proxy store, no
in-proxy cache, no LMDB.
- **Real logic is externalized.** Envoy's own answer to "I have serious
request logic" is `ext_authz` / `ext_proc`: call a service. That is exactly
where the authz app slots in — unchanged except for one new route.

So the proxy gets **only the pure typed core** (validation — per-request, no
state, no I/O), and everything stateful (the proof chain's facts, the durable
log) stays in OpenResty, where cosockets and LMDB live. The one rule from the
OpenResty examples carries over verbatim: never Shen's blocking file I/O on the
request path.

## The mechanics worth knowing

- **Boot is per worker thread.** `filter.lua`'s top level is Envoy's analogue
of `init_worker_by_lua`: it runs once per worker thread at config load —
never per request. `--concurrency 2` keeps the demo's boot cost and memory
footprint small.
- **Two sharp edges this example surfaced — both now handled by `boot.lua`
automatically** (found by running against Homebrew Envoy 1.39 on an arm64
Mac):
- **Per-build bytecode caches.** Bytecode is only portable within the exact
LuaJIT build, and Envoy's LuaJIT is not your local `luajit` — with the old
single shared cache file the two hosts invalidated and rewrote each
other's cache on every alternation, recompiling the kernel every time.
The default cache path is now per build
(`.shen-kernel-cache.<build>.bin`), so Envoy and your shell each keep
their own warm cache in the cwd. `SHEN_KERNEL_CACHE` still relocates or
disables it.
- **JIT-denied hosts run interpreted.** macOS's hardened runtime denies
Envoy's binary executable trace memory: `jit.status()` reports true, but
every trace attempt aborts with `failed to allocate mcode memory` and is
endlessly re-attempted — measured, a hot loop ran ~550× slower than local
`luajit` and kernel boots took 40–66 s. `boot.lua` now probes for exactly
this (compile one throwaway trace, watch for its stop event) and falls
back to the interpreter with a note on stderr: **~2 s to serving cold,
~1 s warm**, edge requests at **1.5–3 ms**. Boot is the only JIT-hungry
phase — per-request validation is tiny either way. On hosts where the JIT
works (OpenResty's nginx, your shell's `luajit`, typical Linux Envoy
builds) the probe compiles one trace and changes nothing. `SHEN_JIT=on`
skips the probe; `SHEN_JIT=off` skips straight to the interpreter.
- **The check endpoint fails closed, twice.** An unmapped path maps to
resource `""` → owner tenant `""` → `denied "unknown resource"` (the app);
and `failure_mode_allow: false` means an unreachable authz app is a 403,
never an allow (Envoy). The check response body is a *typed* projection —
`check-response` in `authz.shen` is total over `decision` and structurally
cannot include document content, so the gateway cannot become a data leak.
- **Path resolution.** `filter.lua` finds the repo via its own file path (or
`$SHEN_LUA_ROOT`, or the cwd as a last resort). Envoy loads it with
`default_source_code: { filename: ... }`, resolved against the cwd.
- **FFI.** The native soa32 Prolog/typecheck engine wants LuaJIT's FFI, and
Envoy's bundled LuaJIT ships it (verified: `require("ffi")` loads and the
typed `rules.shen` load — which runs the typechecker — works in-proxy). In a
build without it, shen-lua falls back to the (slower, still correct) legacy
engine.
- **`x-shen-edge`.** The filter stamps `x-shen-edge: validated` on requests it
lets through (and `rejected` on its 400s), so the origin — and your access
logs — can see the edge ran the rules.
Loading
Loading