firegex/frontend/src/js/demo.ts
2026-10-16 18:06:50 +02:00

810 lines
45 KiB
TypeScript

/**
* In-memory stand-in for the firegex backend, used only by the public demo build
* (`bun run build:demo`, i.e. VITE_DEMO=true).
*
* A normal build never ships this: `utils.tsx` guards every reference behind IS_DEMO,
* which vite inlines as a literal `false`, so rollup drops the module and its seed data.
* Keep it free of top-level side effects, or that tree-shaking stops working.
*
* It covers the subset of the API the frontend actually calls, over mutable state, so a
* visitor can create, edit, start, stop and delete things and watch the UI react exactly
* as it would against a real instance. Nothing is persisted: a reload restores the seed.
*/
type Json = Record<string, any>
const uuid = () => (crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(16).slice(2))
const DEMO_CERT = "-----BEGIN CERTIFICATE-----\n(demo placeholder, no real key material is shipped)\n-----END CERTIFICATE-----\n"
// Three functions in one file, because that is the normal case and it is what the
// per-function switches on the filter card are for. Nothing here declares a protocol:
// asking for an HttpRequest is what makes this an HTTP filter, and the RawPacket one
// sits beside it because HTTP provides both.
const SAMPLE_FILTER = `from firegex.pyfilters import pyfilter, ACCEPT, REJECT
from firegex.pyfilters.models import HttpRequest, RawPacket
@pyfilter
def block_path_traversal(http_request: HttpRequest):
"""Drop any request trying to climb out of the web root."""
if ".." in http_request.url:
return REJECT
return ACCEPT
@pyfilter
def refuse_flag_leaks(packet: RawPacket):
"""Never let a flag leave, whatever the request looked like."""
if not packet.is_input and b"FLAG{" in packet.data:
return REJECT
return ACCEPT
@pyfilter
def log_client(packet: RawPacket):
"""Noisy while debugging, switched off once it has served its purpose."""
print("chunk from", packet.client_ip, packet.client_port)
return ACCEPT
`
const b64 = (text: string) => btoa(text)
// A trimmed copy of what the backend introspects out of the library, so the demo's
// editor offers the same completion. Short on purpose: the real one is generated.
const DEMO_PYFILTER_API = {
models: [
{
name: "RawPacket", protocols: ["http", "tcp"],
doc: "One chunk of a connection, with what is known about where it came from. "
+ "Everything below the application layer is metadata, and read-only.",
members: [
{ name: "client_ip", doc: "The client's address, whichever way this chunk is going", writable: false },
{ name: "client_port", doc: "The client's port, whichever way this chunk is going", writable: false },
{ name: "data", doc: "The application payload, as it arrived. Read-only: a filter answers with a verdict", writable: false },
{ name: "data_size", doc: "How many bytes of application payload this chunk carries", writable: false },
{ name: "dst_ip", doc: "Where this chunk is going", writable: false },
{ name: "dst_port", doc: "The port this chunk is going to", writable: false },
{ name: "is_input", doc: "True if the chunk is going from the client to the service", writable: false },
{ name: "is_ipv6", doc: "True if the connection is IPv6, false if it is IPv4", writable: false },
{ name: "is_tcp", doc: "True if the connection is TCP, false if it is UDP", writable: false },
{ name: "server_ip", doc: "The protected service's address", writable: false },
{ name: "server_port", doc: "The protected service's port", writable: false },
{ name: "src_ip", doc: "Where this chunk came from", writable: false },
{ name: "src_port", doc: "The port this chunk came from", writable: false },
],
},
{
name: "HttpRequest", protocols: ["http"],
doc: "The current HTTP request, once its headers have been parsed.",
members: [
{ name: "body", doc: "The request body, when it is complete", writable: false },
{ name: "get_header", doc: "One header by name", writable: false, signature: "get_header(name)" },
{ name: "headers", doc: "Every header of the request", writable: false },
{ name: "method", doc: "The request method", writable: false },
{ name: "url", doc: "The request target", writable: false },
],
},
{
name: "TCPInputStream", protocols: ["http", "tcp"],
doc: "The assembled client-to-service stream. A filter using it is only called for incoming data.",
members: [
{ name: "data", doc: "The entire input-direction stream assembled so far", writable: false },
{ name: "is_ipv6", doc: "True if the connection is IPv6", writable: false },
{ name: "total_stream_size", doc: "Size of that stream", writable: false },
],
},
],
verdicts: [
{ name: "ACCEPT", value: 0, doc: "Forward this chunk. Returning None means the same thing.", values: [] },
{ name: "REJECT", value: 2, doc: "Refuse the connection. Everything still in the stream goes with it.", values: [] },
{ name: "DROP", value: 1, doc: "Stop the connection's traffic. On NFQUEUE this chunk and every one after it are dropped without closing anything; on the proxy layer, where a stream cannot skip bytes, the connection is closed as REJECT does.", values: [] },
],
settings: [
{ name: "FGEX_STREAM_MAX_SIZE", doc: "Bytes of one stream a model may accumulate.", values: [] },
{ name: "FGEX_FULL_STREAM_ACTION", doc: "What happens when a stream reaches that size.",
values: ["FullStreamAction.FLUSH", "FullStreamAction.ACCEPT", "FullStreamAction.REJECT", "FullStreamAction.DROP"] },
{ name: "FGEX_INVALID_ENCODING_ACTION", doc: "What happens when a parser cannot read the traffic.",
values: ["ExceptionAction.ACCEPT", "ExceptionAction.DROP", "ExceptionAction.REJECT", "ExceptionAction.NOACTION"] },
],
}
// ------------------------------------------------------------------ seed state
const svcShop = uuid(), svcScore = uuid(), svcVault = uuid(), svcArena = uuid()
const fltShopRegex = uuid(), fltShopPy = uuid(), fltScoreRegex = uuid(), fltVaultRegex = uuid()
const fltArenaRegex = uuid()
const svcWeb = uuid()
const fltWebPy = uuid()
const svcHijack = uuid()
/** The demo's services have been up for a while; a fresh one starts filtering now. */
const demoStart = Math.floor(Date.now() / 1000) - 6 * 60 * 60
const state = {
interfaces: [
{ name: "lo", addr: "127.0.0.1" },
{ name: "eth0", addr: "10.60.3.1" },
{ name: "eth0", addr: "fd66:666:3::1" },
{ name: "wg0", addr: "10.10.0.3" },
],
// One shape for every service: a network layer, and a chain of filters on it.
services: [
{ service_id: svcShop, name: "shop-api", status: "active", proto: "tcp", transport: "proxy", fail_open: true, max_connections: 512, over_limit_forwards: false, first_byte_timeout: 0, over_limit_hits: 1847, over_limit_first: demoStart, over_limit_last: demoStart + 900, filtering_since: demoStart },
{ service_id: svcScore, name: "scoreboard", status: "active", proto: "tcp", transport: "nfqueue", fail_open: true, max_connections: 0, over_limit_forwards: false, first_byte_timeout: 0, over_limit_hits: 0, over_limit_first: null, over_limit_last: null, filtering_since: demoStart },
{ service_id: svcVault, name: "vault", status: "stop", proto: "tls", transport: "proxy", fail_open: true, max_connections: 0, over_limit_forwards: false, first_byte_timeout: 0, over_limit_hits: 0, over_limit_first: null, over_limit_last: null, filtering_since: null },
// QUIC: UDP on the wire, terminated here because nothing else can see inside it.
// Its filters are written against HTTP because its traffic is HTTP/3, which the
// engine shows the chain as the HTTP/1.1 it would have been.
{ service_id: svcArena, name: "arena-h3", status: "active", proto: "quic", transport: "proxy", fail_open: true, max_connections: 0, over_limit_forwards: false, first_byte_timeout: 0, over_limit_hits: 0, over_limit_first: null, over_limit_last: null, filtering_since: demoStart },
// One service for every version of HTTP: HTTP/1.1 and HTTP/2 on the TCP
// addresses, in the clear on one port and under TLS on the other, and HTTP/3 on
// the UDP one beside them. One certificate and one chain — which is the whole
// point, because covering the three used to take two or three services with the
// same filter copied between them by hand.
{ service_id: svcWeb, name: "shop-web", status: "active", proto: "http", transport: "proxy", fail_open: true, max_connections: 0, over_limit_forwards: false, first_byte_timeout: 0, over_limit_hits: 0, over_limit_first: null, over_limit_last: null, filtering_since: demoStart },
// Handed to a proxy the operator wrote themselves: firegex steers, nothing here inspects.
{ service_id: svcHijack, name: "legacy-ftp", status: "active", proto: "tcp", transport: "external", fail_open: true, max_connections: 0, over_limit_forwards: false, first_byte_timeout: 0, over_limit_hits: 0, over_limit_first: null, over_limit_last: null, filtering_since: demoStart },
] as Json[],
// Where each one is reachable. shop-api answers on both stacks behind one chain,
// which is the case that used to need two services kept in step by hand — and on
// `wg0` by name, the other half of the same idea: an address somebody else hands
// out is still one place this service answers.
addresses: [
{ address_id: uuid(), service_id: svcShop, ip_int: "10.60.3.1/32", port: 9000, proto: "tcp", edge: "tcp", target_port: null, upstream: "same", proxy_ip: null, proxy_port: null },
{ address_id: uuid(), service_id: svcShop, ip_int: "fd66:666:3::1/128", port: 9000, proto: "tcp", edge: "tcp", target_port: null, upstream: "same", proxy_ip: null, proxy_port: null },
{ address_id: uuid(), service_id: svcShop, ip_int: "wg0", port: 9000, proto: "tcp", edge: "tcp", target_port: null, upstream: "same", proxy_ip: null, proxy_port: null },
{ address_id: uuid(), service_id: svcScore, ip_int: "10.60.0.0/16", port: 8080, proto: "tcp", edge: "tcp", target_port: null, upstream: "same", proxy_ip: null, proxy_port: null },
{ address_id: uuid(), service_id: svcVault, ip_int: "10.60.3.1", port: 8443, proto: "tcp", edge: "tls", target_port: null, upstream: "same", proxy_ip: null, proxy_port: null },
// `udp`, because that is what the kernel matches for a QUIC service.
{ address_id: uuid(), service_id: svcArena, ip_int: "10.60.3.1", port: 4433, proto: "udp", edge: "quic", target_port: null, upstream: "same", proxy_ip: null, proxy_port: null },
// The three edges of one HTTP service, and what they say: in the clear on :80,
// TLS on :443, HTTP/3 on :443/udp. The last two are *publications* — the service
// itself only ever answered HTTP/1.1 on :80, and has not moved.
{ address_id: uuid(), service_id: svcWeb, ip_int: "10.60.3.1", port: 80, proto: "tcp", edge: "tcp", target_port: null, upstream: "same", proxy_ip: null, proxy_port: null },
{ address_id: uuid(), service_id: svcWeb, ip_int: "10.60.3.1", port: 443, proto: "tcp", edge: "tls", target_port: 80, upstream: "tcp", proxy_ip: null, proxy_port: null },
{ address_id: uuid(), service_id: svcWeb, ip_int: "10.60.3.1", port: 443, proto: "udp", edge: "quic", target_port: 80, upstream: "tcp", proxy_ip: null, proxy_port: null },
{ address_id: uuid(), service_id: svcHijack, ip_int: "10.60.3.1", port: 21, proto: "tcp", edge: "tcp", target_port: null, upstream: "same", proxy_ip: "127.0.0.1", proxy_port: 12021 },
] as Json[],
filters: [
// shop-api shows what only the proxy layer can do: two kinds, in an order.
{ filter_id: fltShopRegex, service_id: svcShop, position: 0, kind: "regex", proto: "tcp", name: "patterns", active: true, blocked: 368 },
// `http` because its code asks for an HttpRequest, not because anybody said so.
{ filter_id: fltShopPy, service_id: svcShop, position: 1, kind: "pyfilter", proto: "http", name: "python", active: true, blocked: 641 },
{ filter_id: fltScoreRegex, service_id: svcScore, position: 0, kind: "regex", proto: "tcp", name: "patterns", active: true, blocked: 1204 },
{ filter_id: fltVaultRegex, service_id: svcVault, position: 0, kind: "regex", proto: "tcp", name: "patterns", active: true, blocked: 0 },
{ filter_id: fltArenaRegex, service_id: svcArena, position: 0, kind: "regex", proto: "tcp", name: "patterns", active: true, blocked: 52 },
// The same file that would run on a plain HTTP/1.1 service, unchanged — which is
// the point of an `http` service: HTTP/1.1, HTTP/2 and HTTP/3 are all shown to it
// as the same HTTP/1.1, so one filter covers the three edges below.
{ filter_id: fltWebPy, service_id: svcWeb, position: 0, kind: "pyfilter", proto: "http", name: "python", active: true, blocked: 1190 },
] as Json[],
regexes: [
{ regex_id: uuid(), filter_id: fltShopRegex, regex: b64("\\.\\./"), mode: "C", case_sensitive: true, active: true, blocked: 341 },
{ regex_id: uuid(), filter_id: fltShopRegex, regex: b64("FLAG\\{[A-Za-z0-9_]+\\}"), mode: "S", case_sensitive: true, active: true, blocked: 27 },
// A rewriting rule, so the demo shows what only the proxy layer can do.
{ regex_id: uuid(), filter_id: fltScoreRegex, regex: b64("/etc/passwd"), mode: "C", case_sensitive: true, active: true, blocked: 914 },
{ regex_id: uuid(), filter_id: fltScoreRegex, regex: b64("<script>"), mode: "C", case_sensitive: false, active: true, blocked: 233 },
{ regex_id: uuid(), filter_id: fltScoreRegex, regex: b64("[A-Z0-9]{31}="), mode: "S", case_sensitive: true, active: false, blocked: 57 },
{ regex_id: uuid(), filter_id: fltVaultRegex, regex: b64("/proc/self/"), mode: "C", case_sensitive: true, active: true, blocked: 0 },
// Written the way it would be for an HTTP service on TCP, because that is what
// the chain is shown: on the wire this path was a QPACK-compressed header block.
{ regex_id: uuid(), filter_id: fltArenaRegex, regex: b64("GET /internal/"), mode: "C", case_sensitive: true, active: true, blocked: 52 },
] as Json[],
code: { [fltShopPy]: SAMPLE_FILTER, [fltWebPy]: SAMPLE_FILTER } as Record<string, string>,
// One row per @pyfilter the code defines. The code says which exist; these say
// which run — exactly the split the real backend keeps.
functions: [
{ filter_id: fltShopPy, name: "block_path_traversal", active: true, blocked: 641 },
{ filter_id: fltWebPy, name: "block_path_traversal", active: true, blocked: 1190 },
{ filter_id: fltShopPy, name: "refuse_flag_leaks", active: true, blocked: 0 },
// Switched off rather than deleted: the code is still there to turn back on.
{ filter_id: fltShopPy, name: "log_client", active: false, blocked: 0 },
] as Json[],
certs: { [svcVault]: DEMO_CERT, [svcArena]: DEMO_CERT } as Record<string, string>,
firewall: {
enabled: true,
policy: "accept",
rules: [
{ active: true, name: "allow team vpn", proto: "any", src: "10.10.0.0/16", dst: "", port_src_from: 0, port_dst_from: 0, port_src_to: 0, port_dst_to: 0, action: "accept", mode: "in", table: "filter" },
{ active: true, name: "drop ssh from outside", proto: "tcp", src: "0.0.0.0/0", dst: "", port_src_from: 0, port_dst_from: 22, port_src_to: 0, port_dst_to: 22, action: "drop", mode: "in", table: "filter" },
{ active: false, name: "reject smtp egress", proto: "tcp", src: "", dst: "0.0.0.0/0", port_src_from: 0, port_dst_from: 25, port_src_to: 0, port_dst_to: 25, action: "reject", mode: "out", table: "filter" },
] as Json[],
settings: {
keep_rules: false, allow_loopback: true, allow_established: true, allow_icmp: true,
multicast_dns: false, allow_upnp: false, drop_invalid: true, allow_dhcp: true,
allow_dnat: true,
} as Json,
},
}
// --------------------------------------------------------------- update events
// Mirrors the backend's single "update" socket.io event: the payload is a react-query
// key prefix, and App.tsx invalidates every query starting with it.
type Listener = (payload: any) => void
const listeners: Record<string, Listener[]> = {}
// The live log, mirroring the backend's bounded per-service tail.
const logs: Record<string, Json[]> = {}
let logSeq = 0
const addLog = (service_id: string, level: string, text: string) => {
const entry = { at: Date.now(), level, text, seq: ++logSeq }
logs[service_id] = [...(logs[service_id] ?? []), entry].slice(-500)
for (const cb of listeners["log"] ?? []) cb({ service_id, entries: [entry] })
}
// One warning already in the tail, so the list shows what a problem looks like: the line a
// service at its connection limit really writes.
addLog(svcShop, "warn", "the 512 connection limit turned away 37 more: they were refused. "
+ "Raise the limit, or find out who is holding connections open.")
let ticker: ReturnType<typeof setInterval> | null = null
const emit = (...tags: string[][]) => {
for (const tag of tags) for (const cb of listeners["update"] ?? []) cb(tag)
}
/** Nudges the counters of everything that is running, so the demo looks alive. */
const startTicker = () => {
if (ticker) return
ticker = setInterval(() => {
let changed = false
const running = new Set(state.services.filter(s => s.status === "active").map(s => s.service_id))
for (const f of state.filters) {
if (!f.active || !running.has(f.service_id)) continue
if (Math.random() < 0.4) {
f.blocked += 1
changed = true
addLog(f.service_id, "block", `connection refused by ${f.name}`)
}
}
for (const r of state.regexes) {
const parent = state.filters.find(f => f.filter_id === r.filter_id)
if (!r.active || !parent?.active || !running.has(parent.service_id)) continue
if (Math.random() < 0.3) { r.blocked += 1; changed = true }
}
if (changed) emit(["services"])
}, 4000)
// And one warning a little after the start, the way a real one arrives: as a
// notification wherever you are, and as the badge on the service's row.
setTimeout(() => {
addLog(svcShop, "warn", "the 512 connection limit turned away 12 more: they were refused. "
+ "Raise the limit, or find out who is holding connections open.")
emit(["services"])
}, 10_000)
}
let welcomed = false
const welcome = () => {
if (welcomed) return
welcomed = true
// Imported lazily so this module stays free of top-level side effects.
import("@mantine/notifications").then(({ showNotification }) => showNotification({
title: "This is a demo",
message: "Everything here is fake data running in your browser - no firewall is attached. " +
"Create, start and stop whatever you like; a reload puts it all back.",
color: "cyan",
autoClose: 8000,
}))
}
export const demoSocket = {
auth: {} as Json,
connected: false,
connect() {
this.connected = true
startTicker()
welcome()
for (const cb of listeners["connect"] ?? []) cb([])
},
disconnect() { this.connected = false },
on(event: string, cb: Listener) { (listeners[event] ??= []).push(cb) },
off(event: string) { delete listeners[event] },
emit() { /* the real client sends nothing the demo needs to act on */ },
}
// -------------------------------------------------------------------- handlers
const ok = { status: "ok" }
const notFound = (what: string) => { throw `${what} not found` }
const service = (id: string) => state.services.find(s => s.service_id === id) ?? notFound("Service")
const filter = (sid: string, fid: string) =>
state.filters.find(f => f.filter_id === fid && f.service_id === sid) ?? notFound("Filter")
const chainOf = (sid: string) =>
state.filters.filter(f => f.service_id === sid).sort((a, b) => a.position - b.position)
const addressesOf = (sid: string) => state.addresses.filter(a => a.service_id === sid)
/** The backend's `_note_filtering`: the first moment a service could refuse something —
* running, with an active filter — written once and never moved. */
const noteFiltering = (sid: string) => {
const srv = service(sid)
if (srv.filtering_since != null) return
if (srv.status !== "active") return
if (!chainOf(sid).some(f => f.active)) return
// Aligned to its bucket, like the backend: the mark names the first minute that
// can hold one of this service's blocks.
srv.filtering_since = Math.floor(Date.now() / 1000 / 60) * 60
}
const decorate = (s: Json) => ({
...s,
// The material itself never leaves the backend; only whether there is any.
has_tls_material: !!state.certs[s.service_id],
addresses: addressesOf(s.service_id),
n_filters: state.filters.filter(f => f.service_id === s.service_id).length,
n_blocked: state.filters.filter(f => f.service_id === s.service_id).reduce((acc, f) => acc + f.blocked, 0),
// The newest warning or error in its tail, as the backend reports it.
problem: [...(logs[s.service_id] ?? [])].reverse()
.find(e => e.level === "warn" || e.level === "error") ?? null,
})
/**
* The demo cannot run hyperscan, so the tester falls back to JavaScript's own engine.
*
* That is a real difference and worth saying out loud: against a live instance the
* tester runs the very engine that will enforce the answer, which is the whole reason
* it is trustworthy. Here it can only approximate, so a pattern JavaScript accepts and
* hyperscan does not will look fine in the demo and be refused on a real install.
*/
const demoDebug = (patterns: Json[], sample: string) => {
let text = ""
try { text = atob(sample) } catch { text = "" }
const matches: Json[] = []
const errors: Json[] = []
for (const p of patterns) {
let re: RegExp
try {
re = new RegExp(p.expr, p.case_sensitive ? "g" : "gi")
} catch (err) {
errors.push({ id: p.id, error: `${err}` })
continue
}
for (const hit of text.matchAll(re)) {
if (hit.index === undefined) continue
matches.push({ id: p.id, start: hit.index, end: hit.index + hit[0].length })
if (hit[0].length === 0) break // a zero-width match would never advance
if (matches.length >= 1000) break
}
}
return {
matches, errors, error: null, unscannable: [],
truncated: matches.length >= 1000,
}
}
type Handler = (m: RegExpMatchArray, body: Json, query: Record<string, string>) => any
const routes: [string, RegExp, Handler][] = [
// ---- global
["GET", /^status$/, () => ({ status: "run", loggined: true, version: import.meta.env.VITE_VERSION || "demo", auth_disabled: false })],
["POST", /^login$/, () => ({ access_token: "demo-token", token_type: "bearer" })],
["POST", /^set-password$/, () => ok],
["POST", /^change-password$/, () => ({ ...ok, access_token: "demo-token" })],
// The demo has no authentication to turn off, and saying so is more use than
// pretending it worked on a page anyone can open.
["POST", /^auth-mode$/, () => { throw "The demo has no authentication to turn off" }],
["GET", /^interfaces$/, () => state.interfaces],
["POST", /^reset$/, () => { emit(["services"], ["firewall"]); return ok }],
["GET", /^export$/, () => ({ "firegex.db": { keys_values: [] }, note: "demo export - not a real backup" })],
["POST", /^import$/, () => { emit(["services"], ["firewall"]); return ok }],
// ---- services: the network layer
["GET", /^services$/, () => state.services.map(decorate)],
["POST", /^services$/, (_m, b) => {
const service_id = uuid()
state.services.push({
service_id, name: b.name, status: "stop", proto: b.proto ?? "tcp",
transport: b.transport ?? "proxy",
fail_open: b.fail_open ?? true,
max_connections: b.max_connections ?? 0,
over_limit_forwards: b.over_limit_forwards ?? false,
first_byte_timeout: b.first_byte_timeout ?? 0,
over_limit_hits: 0, over_limit_first: null, over_limit_last: null,
})
for (const a of (b.addresses ?? [])) {
state.addresses.push({
address_id: uuid(), service_id, ip_int: a.ip_int, port: a.port,
proto: b.proto === "http" ? (a.edge === "quic" ? "udp" : "tcp")
: b.proto === "quic" ? "udp" : b.proto === "udp" ? "udp" : "tcp",
edge: b.proto === "http" ? (a.edge ?? "tcp") : (b.proto ?? "tcp"),
target_port: a.target_port ?? null,
upstream: a.upstream ?? "same",
proxy_ip: a.proxy_ip ?? null, proxy_port: a.proxy_port ?? null,
})
}
if (b.tls_cert) state.certs[service_id] = b.tls_cert
emit(["services"])
return { status: "ok", service_id }
}],
["GET", /^services\/([^/]+)$/, m => decorate(service(m[1]))],
["PUT", /^services\/([^/]+)$/, (m, b) => {
const srv = service(m[1])
// The state the edit lands on is what has to hold: turning TLS on for a service
// that has never been given a certificate is refused here as it is by the real
// backend, rather than by nginx on a later start.
const landsOnTls = (b.proto ?? srv.proto) === "tls"
if (landsOnTls && !b.tls_cert && !state.certs[srv.service_id])
throw "A service that speaks TLS needs a certificate and a private key."
for (const key of ["name", "proto", "transport", "fail_open",
"max_connections", "over_limit_forwards",
"first_byte_timeout"]) {
if (b[key] !== undefined && b[key] !== null) srv[key] = b[key]
}
// The addresses carry it too, so `(ip, port, proto)` stays a usable key.
if (b.proto) for (const a of addressesOf(srv.service_id)) a.proto = b.proto
if (b.tls_cert) state.certs[srv.service_id] = b.tls_cert
emit(["services"]); return ok
}],
["DELETE", /^services\/([^/]+)$/, m => {
const srv = service(m[1])
for (const f of chainOf(srv.service_id)) {
state.regexes = state.regexes.filter(r => r.filter_id !== f.filter_id)
delete state.code[f.filter_id]
}
state.filters = state.filters.filter(f => f.service_id !== srv.service_id)
state.addresses = state.addresses.filter(a => a.service_id !== srv.service_id)
state.services = state.services.filter(s => s.service_id !== srv.service_id)
emit(["services"]); return ok
}],
// ---- services: where they are reachable
["GET", /^services\/([^/]+)\/addresses$/, m => { service(m[1]); return addressesOf(m[1]) }],
["POST", /^services\/([^/]+)\/addresses$/, (m, b) => {
const srv = service(m[1])
if (srv.transport === "external" && !b.proxy_port) {
throw "This service hands its traffic to your own proxy, so the new address " +
"needs the port that proxy listens on for it"
}
if (state.addresses.some(a => a.ip_int === b.ip_int && a.port === b.port && a.proto === srv.proto))
throw "one of these addresses is already protected by a service"
state.addresses.push({
address_id: uuid(), service_id: srv.service_id, ip_int: b.ip_int, port: b.port,
proto: srv.proto, proxy_ip: b.proxy_ip ?? null, proxy_port: b.proxy_port ?? null,
})
addLog(srv.service_id, "info",
`also protecting ${b.ip_int}:${b.port} (no connection was dropped)`)
emit(["services"]); return ok
}],
["PUT", /^services\/([^/]+)\/addresses\/([^/]+)$/, (m, b) => {
service(m[1])
const addr = state.addresses.find(a => a.address_id === m[2] && a.service_id === m[1])
?? notFound("Address")
addr.ip_int = b.ip_int; addr.port = b.port
addr.proxy_ip = b.proxy_ip ?? null; addr.proxy_port = b.proxy_port ?? null
emit(["services"]); return ok
}],
["DELETE", /^services\/([^/]+)\/addresses\/([^/]+)$/, m => {
service(m[1])
if (addressesOf(m[1]).length === 1)
throw "This is the only address this service protects. Delete the service " +
"itself, or add another address first."
state.addresses = state.addresses.filter(a => a.address_id !== m[2])
emit(["services"]); return ok
}],
["POST", /^services\/([^/]+)\/start$/, m => {
const srv = service(m[1])
// The same refusal a real instance gives, for the same reason: the NFQUEUE
// binaries are the filter, so they cannot host a chain.
const active = chainOf(srv.service_id).filter(f => f.active)
if (srv.transport === "external" && active.length > 0) {
throw `this service hands its traffic to your own proxy, so firegex inspects ` +
`nothing and the ${active.length} filter(s) attached to it would never run.`
}
if (srv.transport === "nfqueue" && active.length > 8) {
throw `the nfqueue transport chains at most 8 filters, and this service has ` +
`${active.length} active. Deactivate some, or move it to the proxy ` +
`transport, which walks the chain inside one process.`
}
srv.status = "active"
noteFiltering(srv.service_id)
addLog(srv.service_id,
"info",
`started on the ${srv.transport} layer, ${active.length} filter(s) active`)
emit(["services"]); return ok
}],
["POST", /^services\/([^/]+)\/stop$/, m => {
const srv = service(m[1])
srv.status = "stop"
addLog(srv.service_id, "info", "stopped")
emit(["services"]); return ok
}],
["GET", /^services\/([^/]+)\/stats$/, (m, _b, query) => {
service(m[1])
const chain = chainOf(m[1])
// The real instant, not the bucket it falls in — the same reason the backend
// stopped truncating it: the current, partial minute would otherwise be
// reported as ending at the moment it began.
const now = Math.floor(Date.now() / 1000)
const keptFrom = now - 48 * 60 * 60
const began = service(m[1]).filtering_since as number | null
const to = Math.min(Number(query?.range_to) || now, now)
// Two floors, like the backend: what is still kept, and when this service first
// became able to refuse anything — hours before it existed are not quiet hours.
// The second one applies only to a window that reaches into its life; one that
// ended before it began is answered as asked, and is empty because it is.
const from = Math.max(Number(query?.range_from) || to - 3600, keptFrom,
began !== null && to >= began ? began : keptFrom)
// The bucket widens with the range, exactly as the backend does it, so the demo
// shows the same handful of bars whatever window is picked — unless a step was
// asked for, which wins until it would draw more bars than a chart can carry.
const span = Math.max(60, to - from + 60)
const want = Number(query?.step) || 0
const least = Math.ceil(span / 60 / 400)
const width = 60 * Math.max(1, least, want
? Math.round(want / 60)
: Math.ceil(span / 60 / 60))
const first = Math.floor(from / width) * width
const buckets: number[] = []
for (let edge = first; edge <= to; edge += width) buckets.push(edge)
// A plausible shape rather than a real history, scaled to the window so the
// numbers move when the range does.
const fraction = Math.min(1, (to - from) / (48 * 60 * 60))
const scaled: Json[] = chain.map(f => ({
...f, ranged: Math.round(f.blocked * (0.15 + 0.85 * fraction)),
}))
const total = scaled.reduce((acc, f) => acc + f.ranged, 0)
const shared = (rows: Json[]) => rows.map(r => ({
...r, share: total ? Math.round((1000 * r.blocked) / total) / 10 : 0,
}))
const spread = (amount: number, seed: number) => {
const raw = buckets.map((_, i) => Math.abs(Math.sin((i + seed) / 5)) + 0.05)
const sum = raw.reduce((a, b) => a + b, 0)
return raw.map(v => Math.round((v / sum) * amount))
}
const busy = scaled.filter(f => f.ranged > 0)
const patterns = state.regexes
.filter(r => chain.some(f => f.filter_id === r.filter_id))
.map(r => ({
id: r.regex_id, name: atob(r.regex), kind: "regex",
filter_id: r.filter_id,
blocked: Math.round(r.blocked * (0.15 + 0.85 * fraction)),
}))
const functions = state.functions
.filter(fn => chain.some(f => f.filter_id === fn.filter_id))
.map(fn => ({
id: `${fn.filter_id}/${fn.name}`, name: fn.name, kind: "pyfilter",
filter_id: fn.filter_id,
blocked: Math.round(fn.blocked * (0.15 + 0.85 * fraction)),
}))
const srv = service(m[1])
// A proxy service counts connections, which is the unit a block is in, so its
// refused share is exact; the nfqueue layer works per packet and reports none.
const connections = srv.transport === "proxy" ? 30118 : null
return {
filters: shared(scaled.map(f => ({
id: f.filter_id, name: f.name, kind: f.kind,
blocked: f.ranged, all_time: f.blocked,
}))),
patterns: shared(patterns),
functions: shared(functions),
buckets,
bucket_seconds: width,
series: busy.map(f => ({
id: f.filter_id, name: f.name, counts: spread(f.ranged, f.blocked),
})),
total,
all_time: chain.reduce((acc, f) => acc + f.blocked, 0),
range_from: from,
range_to: to,
kept_from: keptFrom,
filtering_since: began,
traffic: {
packets: srv.transport === "proxy" ? 0 : 1_284_402,
bytes: srv.transport === "proxy" ? 0 : 903_118_774,
connections,
connections_refused: connections === null ? null : total,
refused_share: connections === null
? null : Math.round((10000 * total) / connections) / 100,
},
}
}],
["GET", /^services\/([^/]+)\/logs$/, m => { service(m[1]); return logs[m[1]] ?? [] }],
["DELETE", /^services\/([^/]+)\/logs$/, m => { service(m[1]); logs[m[1]] = []; emit(["services"]); return ok }],
// ---- services: the filter chain
["GET", /^services\/([^/]+)\/filters$/, m => {
service(m[1])
return chainOf(m[1]).map(f => {
const fns = state.functions.filter(fn => fn.filter_id === f.filter_id)
return {
...f,
n_regexes: state.regexes.filter(r => r.filter_id === f.filter_id).length,
n_functions: fns.length,
n_functions_active: fns.filter(fn => fn.active).length,
}
})
}],
// ---- services: the functions inside one pyfilter
["GET", /^services\/([^/]+)\/filters\/([^/]+)\/functions$/, m => {
const f = filter(m[1], m[2])
if (f.kind !== "pyfilter") throw "This filter is not a pyfilter"
return state.functions.filter(fn => fn.filter_id === m[2])
}],
["PUT", /^services\/([^/]+)\/filters\/([^/]+)\/functions\/([^/]+)$/, (m, b) => {
filter(m[1], m[2])
const fn = state.functions.find(
x => x.filter_id === m[2] && x.name === decodeURIComponent(m[3])
) ?? notFound("Function")
fn.active = b.active
emit(["services"]); return ok
}],
["POST", /^services\/([^/]+)\/filters$/, (m, b) => {
service(m[1])
const filter_id = uuid()
state.filters.push({
filter_id, service_id: m[1], position: chainOf(m[1]).length,
// A new pyfilter asks for nothing yet, so it speaks the simplest protocol
// there is; saving code is what settles it.
kind: b.kind, proto: "tcp",
name: b.name ?? b.kind, active: b.active ?? true, blocked: 0,
})
if (b.kind === "pyfilter") state.code[filter_id] = ""
noteFiltering(m[1])
emit(["services"]); return ok
}],
["POST", /^services\/([^/]+)\/filters\/order$/, (m, b) => {
service(m[1])
const existing = chainOf(m[1]).map(f => f.filter_id).sort()
if (JSON.stringify(existing) !== JSON.stringify([...(b.filters ?? [])].sort()))
throw "The order must list exactly the filters this service has"
b.filters.forEach((id: string, position: number) => {
const f = state.filters.find(x => x.filter_id === id)
if (f) f.position = position
})
emit(["services"]); return ok
}],
["PUT", /^services\/([^/]+)\/filters\/([^/]+)$/, (m, b) => {
const f = filter(m[1], m[2])
if (b.name !== undefined && b.name !== null) f.name = b.name
if (b.active !== undefined && b.active !== null) f.active = b.active
emit(["services"]); return ok
}],
["DELETE", /^services\/([^/]+)\/filters\/([^/]+)$/, m => {
filter(m[1], m[2])
state.regexes = state.regexes.filter(r => r.filter_id !== m[2])
state.functions = state.functions.filter(fn => fn.filter_id !== m[2])
state.filters = state.filters.filter(f => f.filter_id !== m[2])
delete state.code[m[2]]
chainOf(m[1]).forEach((f, position) => { f.position = position })
emit(["services"]); return ok
}],
// ---- services: a pyfilter's code
// The real instance runs the file through the process that will execute it; the
// demo has no Python, so it approximates just enough to show what the editor does
// with the answer — and never claims a file is fine when it obviously is not.
["POST", /^services\/([^/]+)\/filters\/([^/]+)\/check$/, (m, b) => {
filter(m[1], m[2])
const code: string = b.code ?? ""
const lines = code.split("\n")
const bad = lines.findIndex(l => /@pyfilter\s*$/.test(l.trim()) === false
&& /^\s*def\s+\w+\s*\([^)]*\)\s*:/.test(l)
&& !/:\s*\w+\s*[,)]/.test(l))
if (bad >= 0 && /@pyfilter/.test(lines[bad - 1] ?? "")) {
const name = lines[bad].match(/def\s+(\w+)/)?.[1] ?? "?"
const param = lines[bad].match(/\(\s*(\w+)/)?.[1] ?? "?"
return {
ok: false, filters: [],
error: {
type: "Exception",
message: `Parameter '${param}' of ${name} has no type annotation. `
+ "Annotate it with what the filter wants to be given — RawPacket, "
+ "a TCP stream, an HTTP model — because that is what decides when "
+ "it is called.",
line: bad + 1, column: 0, text: lines[bad], traceback: "",
},
}
}
const defined = [...code.matchAll(/@pyfilter\s*\n\s*def\s+([A-Za-z_]\w*)/g)].map(x => x[1])
const http = /\bHttp(Request|Response|RequestHeader|ResponseHeader|FullRequest|FullResponse|History|StreamHistory)\b/.test(code)
return { ok: true, proto: http ? "http" : "tcp", filters: defined, error: null }
}],
["GET", /^services\/pyfilter-api$/, () => DEMO_PYFILTER_API],
["GET", /^services\/([^/]+)\/filters\/([^/]+)\/code$/, m => {
filter(m[1], m[2])
return state.code[m[2]] ?? ""
}],
["PUT", /^services\/([^/]+)\/filters\/([^/]+)\/code$/, (m, b) => {
const f = filter(m[1], m[2])
const code: string = b.code ?? ""
// The real instance asks the library which protocol the file speaks, by looking
// at what its filters are annotated with. Here that is approximated by the model
// names the file mentions — enough to demonstrate that it is read, not chosen,
// and that mixing two application protocols is refused.
const http = /\bHttp(Request|Response|RequestHeader|ResponseHeader|FullRequest|FullResponse|History|StreamHistory)\b/.test(code)
state.code[m[2]] = code
f.proto = http ? "http" : "tcp"
// Reconciled with the code, like the real backend: a function that has gone is
// dropped, a new one arrives switched on, and one that survives keeps whatever
// the operator had set for it.
const defined = [...code.matchAll(/@pyfilter\s*\n\s*def\s+([A-Za-z_]\w*)/g)].map(x => x[1])
state.functions = state.functions.filter(
fn => fn.filter_id !== m[2] || defined.includes(fn.name)
)
for (const name of defined) {
if (!state.functions.some(fn => fn.filter_id === m[2] && fn.name === name))
state.functions.push({ filter_id: m[2], name, active: true, blocked: 0 })
}
emit(["services"]); return ok
}],
// ---- services: a regex filter's patterns
["GET", /^services\/([^/]+)\/filters\/([^/]+)\/regexes$/, m => {
filter(m[1], m[2])
return state.regexes.filter(r => r.filter_id === m[2])
}],
["POST", /^services\/([^/]+)\/filters\/([^/]+)\/regexes$/, (m, b) => {
const f = filter(m[1], m[2])
if (f.kind !== "regex") throw "This filter does not hold patterns"
let expr = ""
try { expr = atob(b.regex) } catch { throw "The pattern must be base64-encoded" }
try { new RegExp(expr) } catch (err) { throw `Invalid pattern: ${err}` }
state.regexes.push({
regex_id: uuid(), filter_id: m[2], regex: b.regex, mode: b.mode ?? "B",
case_sensitive: b.case_sensitive ?? true, active: b.active ?? true, blocked: 0,
})
emit(["services"]); return ok
}],
["PUT", /^services\/([^/]+)\/filters\/([^/]+)\/regexes\/([^/]+)$/, (m, b) => {
filter(m[1], m[2])
const rx = state.regexes.find(r => r.regex_id === m[3]) ?? notFound("Pattern")
if (b.active !== undefined && b.active !== null) rx.active = b.active
if (b.regex != null && b.regex !== rx.regex) {
let expr = ""
try { expr = atob(b.regex) } catch { throw "The pattern must be base64-encoded" }
try { new RegExp(expr) } catch (err) { throw `Invalid pattern: ${err}` }
if (state.regexes.some(r => r.filter_id === m[2] && r.regex_id !== rx.regex_id
&& r.regex === b.regex && r.case_sensitive === (b.case_sensitive ?? rx.case_sensitive)))
throw "This filter already holds that exact pattern"
rx.regex = b.regex
// The counters belonged to the old pattern, so they do not carry over.
rx.blocked = 0
}
if (b.mode != null) rx.mode = b.mode
if (b.case_sensitive != null) rx.case_sensitive = b.case_sensitive
emit(["services"]); return ok
}],
["DELETE", /^services\/([^/]+)\/filters\/([^/]+)\/regexes\/([^/]+)$/, m => {
filter(m[1], m[2])
state.regexes = state.regexes.filter(r => r.regex_id !== m[3])
emit(["services"]); return ok
}],
// ---- services: the pattern tester
["POST", /^services\/debug-regex$/, (_m, b) => demoDebug(b.patterns ?? [], b.sample ?? "")],
// ---- firewall
["GET", /^firewall\/rules$/, () => ({ rules: state.firewall.rules, policy: state.firewall.policy, enabled: state.firewall.enabled })],
["POST", /^firewall\/rules$/, (_m, b) => {
state.firewall.rules = b?.rules ?? []
if (b?.policy) state.firewall.policy = b.policy
emit(["firewall"]); return { status: state.firewall.rules.map((_r, i) => ({ status: "ok", rule_id: i })) }
}],
["GET", /^firewall\/settings$/, () => state.firewall.settings],
["PUT", /^firewall\/settings$/, (_m, b) => { Object.assign(state.firewall.settings, b); emit(["firewall"]); return ok }],
["POST", /^firewall\/(enable|disable)$/, m => { state.firewall.enabled = m[1] === "enable"; emit(["firewall"]); return ok }],
]
/** Same contract as genericapi(): resolves with the parsed body, rejects with a message. */
export async function demoApi(method: string, path: string, body: Json | undefined): Promise<any> {
const trimmed = path.replace(/^\/+|\/+$/g, "")
const clean = trimmed.split("?")[0]
// The query string reaches the handlers too, now that a range is asked for in it.
const query = Object.fromEntries(new URLSearchParams(trimmed.split("?")[1] ?? ""))
await new Promise(r => setTimeout(r, 60 + Math.random() * 120)) // a plausible round trip
for (const [verb, pattern, handler] of routes) {
if (verb !== method.toUpperCase()) continue
const match = clean.match(pattern)
if (match) return handler(match, body ?? {}, query)
}
throw `This endpoint is not available in the demo (${method} /api/${clean})`
}