A local developer HTTPS proxy with MITM interception, rule-based request/response rewriting, and local file serving.
Named after Prostetnic Vogon Jeltz from The Hitchhiker's Guide to the Galaxy.
It provides functionality similar to tools like Charles Proxy and mitmproxy, focused on a lightweight local developer workflow.
Point your browser (or any HTTP client) at jeltz as an explicit HTTP proxy. For plain HTTP traffic it forwards requests and applies rules. For HTTPS traffic it performs MITM interception: it terminates the TLS connection from the client using a locally-generated certificate, applies your rules, then proxies the request to the real upstream.
HTTP/2 is fully supported on the client-to-proxy leg of HTTPS connections. The client-facing listener is HTTP/1.1 (standard for explicit proxies).
Use cases:
- Serve local mock files instead of real upstream responses (
map_local) - Serve inline mock payloads directly from config (
map) - Remap requests to a different remote upstream (
map_remote) - Redirect matching requests to rewritten URLs (
redirect) - Inject, replace, or strip request and response headers
- Search/replace response body payloads (
body_replace) - Strip tracking cookies, GDPR consent headers, etc.
- Inspect traffic with structured logging
- Go 1.25+
git clone https://github.com/fabiant7t/jeltz
cd jeltz
make build # produces ./jeltz binary
make test # run tests
make race # run tests with race detectorCI runs go test ./... on Linux, macOS, and Windows.
# 1. Install the CA certificate so your browser trusts jeltz's MITM certs
jeltz ca-install-hint
# 2. Write a config (optional — jeltz works with no config too)
mkdir -p ~/.config/jeltz
cat > ~/.config/jeltz/config.yaml <<'EOF'
version: 1
listen: "127.0.0.1:8080"
rules: []
EOF
# 3. Start the proxy
jeltz
# 4. Point your browser or tool at http://127.0.0.1:8080
# e.g. curl --proxy http://127.0.0.1:8080 https://example.com/On first run jeltz generates a root CA (ca.key.pem + ca.crt.pem) in its data directory. All HTTPS leaf certificates are issued from this CA and cached in-memory (LRU, max 1024 entries).
jeltz ca-pathjeltz ca-p12-pathjeltz ca-install-hintUnknown subcommands now fail fast with a clear error (they do not fall through and start the proxy). CA path/install-hint output and startup banner rendering are covered by unit tests.
On first run jeltz writes three files to its data directory:
| File | Use |
|---|---|
ca.crt.pem |
PEM certificate — use with security, certutil, update-ca-certificates |
ca.key.pem |
Private key — keep private |
ca.p12 |
PKCS#12 bundle (password: jeltz) — optional convenience bundle (Firefox uses ca.crt.pem) |
The CA certificate must be trusted by your OS or browser before HTTPS interception works without certificate errors.
| Flag | Default | Description |
|---|---|---|
-listen |
127.0.0.1:8080 |
Proxy listen address |
-config |
~/.config/jeltz/config.yaml (if it exists) |
Path to config file |
-base-path |
XDG config dir | Base path for resolving relative path values in rules |
-data-dir |
XDG data dir (~/.local/share/jeltz) |
Directory for CA key/cert and CA bundle files |
-log-level |
info |
Log level: debug, info, warn, error |
-ui |
false |
Start an interactive TUI with live, filterable logs |
-insecure-upstream |
false |
Skip TLS certificate verification for upstream connections |
-dump-traffic |
false |
Log request/response headers and body snippets at debug level |
-max-body-bytes |
1048576 |
Max body bytes to log when -dump-traffic is enabled |
-max-upstream-request-body-bytes |
0 |
Max upstream request body bytes (0 = unlimited), over-limit returns 413 |
-upstream-dial-timeout-ms |
10000 |
Upstream TCP dial timeout in milliseconds |
-upstream-tls-handshake-timeout-ms |
10000 |
Upstream TLS handshake timeout in milliseconds |
-upstream-response-header-timeout-ms |
30000 |
Upstream response header timeout in milliseconds |
-upstream-idle-conn-timeout-ms |
60000 |
Upstream idle connection timeout in milliseconds |
CLI flags override config file values. For bool/int flags, overrides apply only when the flag is explicitly provided on the CLI. Config file values override environment variables (JELTZ_ prefix). Environment variables override built-in defaults.
- Navigation:
j/k(line),Ctrl-d/Ctrl-u(half-page),g/G(top/bottom) - Search:
/then type query,Enterto apply,Escto cancel - Visibility dialog:
vopens per-key visibility settings (spacetoggle,aall on,nall off) - Other:
fcycle level filter,stoggle autoscroll,wtoggle wrap,cclear,qquit
The config file is YAML. Unknown keys are rejected. The only supported version is 1.
version: 1
listen: "127.0.0.1:8080" # proxy listen address
base_path: "." # base for relative rule paths; "." = config dir
data_dir: "" # CA/cert storage; empty = XDG data dir
rule_sources: [] # optional external rule files/dirs/globs (loaded after inline rules)
insecure_upstream: false # skip TLS verification upstream
dump_traffic: false # log headers + body snippets
max_body_bytes: 1048576 # body dump limit in bytes
max_upstream_request_body_bytes: 0 # max upstream request body bytes (0 = unlimited)
upstream_dial_timeout_ms: 10000 # upstream TCP dial timeout
upstream_tls_handshake_timeout_ms: 10000 # upstream TLS handshake timeout
upstream_response_header_timeout_ms: 30000 # upstream response header timeout
upstream_idle_conn_timeout_ms: 60000 # upstream idle conn timeout
rules: [] # ordered list of rulesbase_pathempty or"."→ the directory containingconfig.yaml(XDG config dir)base_pathrelative → resolved relative to the XDG config dirbase_pathabsolute → used as-is (less portable)- Rule
pathvalues that are relative are resolved againstbase_path
rule_sourcesentries can be:- a YAML file (
.yaml/.yml) - a directory (loaded recursively for
.yaml/.yml) - a glob pattern (for example
rules/**/*.yaml)
- a YAML file (
- Relative paths are resolved from the directory containing
config.yaml. - Loaded rules are appended after inline
rulesfromconfig.yaml. - Rule source files may be either:
- top-level
rules: [...] - top-level sequence
[...]
- top-level
Rules are evaluated in file order. All matching header rules apply to every request/response. redirect, map/map_local, and map_remote use first-match-wins in their stages. Matching body_replace rules are applied in file order.
All rule types support optional enabled: false. If omitted, rules are enabled by default.
- Apply matching request header rules (delete then set)
- Check
redirectrules in file order (first match wins) - If no redirect matched, check
map/map_localrules in file order (first match wins) - If no local map matched, check
map_remoterules (first match wins) - Proxy to upstream (original or remapped)
- Apply matching body_replace rules (replace-all, in file order)
- Apply matching response header rules (delete then set)
- Apply matched
redirect/map/map_localrule's ownresponseops (after global response rules)
Transforms request and/or response headers for matching traffic.
- type: header
match:
methods: ["GET", "POST"] # optional; omit to match any method
host: "^example\\.com$" # required; regex matched against hostname only (no port)
path: "^/api/" # required; regex matched against URL path
request:
delete:
- name: "Cookie" # delete all values of this header
- name: "Cookie" # delete only values matching the pattern
value: "^session="
- any_name: true # delete values matching regex across ALL headers
value: "^GDPR=$"
set:
- name: "X-Debug"
mode: replace # replace: overwrite any existing values
value: "true"
- name: "X-Request-Id"
mode: append # append: add alongside existing values
value: "jeltz"
response:
set:
- name: "X-From-Jeltz"
mode: append
value: "1"Delete op variants:
| Fields | Behaviour |
|---|---|
name: "Foo" |
Remove all values of header Foo |
name: "Foo", value: "^bar" |
Remove only values of Foo matching the regex pattern; keep the rest |
any_name: true, value: "^bar" |
Remove any value matching the regex pattern across every header name |
value in a delete op is a regex pattern. value in a set op is a literal string.
Delete ops run before set ops within the same block.
Serve a local file or directory instead of proxying to the upstream.
- type: map_local
match:
methods: ["GET"]
host: "^example\\.com$"
path: "^/static/" # MUST start with ^ (required for prefix stripping)
path: "mocks/static" # relative to base_path, or absolute
index_file: "index.html" # served when the stripped path ends with / (default: index.html)
status_code: 200 # response status (default: 200)
content_type: "" # override Content-Type; empty = auto-detect
response: # extra response header ops applied after global rules
set:
- name: "Cache-Control"
mode: replace
value: "no-store"Prefix stripping: the matched prefix (the part of the URL path the path regex consumed) is stripped before looking up the file. /static/js/app.js with regex ^/static/ resolves to mocks/static/js/app.js.
The path regex must begin with ^. Traversal attempts (../../) are neutralised by URL path normalisation and a filesystem containment check.
If path points to a file, that file is always served regardless of the URL path. If it points to a directory, the stripped URL path is joined to it.
Content-Type is determined by: explicit content_type → file extension → Content-Type sniffing → application/octet-stream.
map_local responses are streamed from disk; files are not fully buffered in memory before being sent.
Serve a response body directly from the rule YAML instead of proxying upstream.
- type: map
match:
methods: ["GET"]
host: "^api\\.example\\.com$"
path: "^/v1/health$"
status_code: 200
content_type: "application/json"
body: |
{"ok":true}
response:
set:
- name: "Cache-Control"
mode: replace
value: "no-store"Binary payloads are supported via body_base64:
- type: map
match:
host: "^example\\.com$"
path: "^/asset.bin$"
content_type: "application/octet-stream"
body_base64: "AAEC/w=="Exactly one of body or body_base64 must be provided.
Proxy matching requests to a different remote upstream URL.
- type: map_remote
match:
methods: ["GET", "POST"]
host: "^api\\.example\\.com$"
path: "^/v1/" # MUST start with ^
url: "https://staging-api.example.net/mirror/?env=dev"Behavior:
urlmust be an absolute URL with scheme and host.- Prefix stripping mirrors
map_local: withpath: ^/v1/, request/v1/usersmaps to/mirror/users. - Original request query is preserved and appended after the mapped URL query.
map_localtakes precedence if bothmap_localandmap_remotematch.
Return an HTTP redirect to a rewritten URL for matching requests.
- type: redirect
match:
methods: ["GET"]
host: "^www\\.example\\.com$"
path: "^/old/"
search: "^https://www\\.example\\.com/old/(.*)$" # regex by default
replace: "https://www.example.com/new/$1"
search_mode: regex # optional: regex (default) or literal
status_code: 302 # optional: defaults to 302, must be 3xx
response: # optional response header ops
set:
- name: "Cache-Control"
mode: replace
value: "no-store"Behavior:
- Redirect rewrite input is the full request URL:
scheme://host[:port]/path?query(default ports are omitted:https:443,http:80). search_mode: regexuses Go regex replacement semantics ($1,$2, ... supported inreplace).search_mode: literaltreatssearchas an exact string.- Redirect is emitted only when the rewrite changes the input URL.
- Optional
responseops are applied after global response header rules. - First matching redirect rule wins.
Search/replace response body payload content for matching traffic.
- type: body_replace
match:
methods: ["GET"]
host: "^api\\.example\\.com$"
path: "^/v1/"
search: "\"featureFlag\":false" # regex by default
replace: "\"featureFlag\":true"
search_mode: regex # optional: regex (default) or literal
content_type: "^application/json" # optional regex filterBehavior:
search_mode: regexuses Go regex replacement semantics ($1,$2, ... supported inreplace).search_mode: literaltreatssearchas an exact string.- Replacement is applied to all matches.
content_typeis optional; if set, only responses whoseContent-Typeheader matches the regex are rewritten.
version: 1
listen: "127.0.0.1:8080"
base_path: "."
rule_sources:
- "rules/**/*.yaml"
insecure_upstream: false
upstream_dial_timeout_ms: 10000
upstream_tls_handshake_timeout_ms: 10000
upstream_response_header_timeout_ms: 30000
upstream_idle_conn_timeout_ms: 60000
max_upstream_request_body_bytes: 0
rules:
# Strip GDPR consent cookies from every request
- type: header
match:
host: ".*"
path: ".*"
request:
delete:
- any_name: true
value: "^gdpr_consent="
# Add a debug header and strip X-Powered-By from responses to the API
- type: header
match:
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"]
host: "^api\\.example\\.com$"
path: "^/"
request:
set:
- name: "X-Debug"
mode: replace
value: "1"
response:
delete:
- name: "X-Powered-By"
# Serve local mock files for the frontend's static assets
- type: map_local
match:
methods: ["GET"]
host: "^www\\.example\\.com$"
path: "^/static/"
path: "mocks/static"
response:
set:
- name: "Cache-Control"
mode: replace
value: "no-store"
# Return a fast inline health response without hitting upstream
- type: map
match:
methods: ["GET"]
host: "^api\\.example\\.com$"
path: "^/v1/health$"
status_code: 200
content_type: "application/json"
body: |
{"ok":true}
# Always return a fixed JSON fixture for one endpoint
- type: map_local
match:
methods: ["GET"]
host: "^api\\.example\\.com$"
path: "^/v1/features"
path: "mocks/features.json"
content_type: "application/json"
# Route selected API calls to a remote staging backend
- type: map_remote
match:
methods: ["GET", "POST"]
host: "^api\\.example\\.com$"
path: "^/v1/"
url: "https://staging-api.example.net/mirror/?env=dev"
# Redirect legacy paths to a new URL structure
- type: redirect
match:
methods: ["GET"]
host: "^www\\.example\\.com$"
path: "^/legacy/"
search: "^https://www\\.example\\.com/legacy/(.*)$"
replace: "https://www.example.com/new/$1"
status_code: 301
response:
set:
- name: "X-Redirect-By"
mode: replace
value: "jeltz"
# Rewrite an API field in JSON responses
- type: body_replace
match:
methods: ["GET"]
host: "^api\\.example\\.com$"
path: "^/v1/features"
search: "\"enabled\":false"
replace: "\"enabled\":true"
content_type: "^application/json"jeltz writes structured text logs to stderr. All events share a stable set of keys:
| Key | Description |
|---|---|
component |
Subsystem (main, proxy, mitm, pipeline, dump) |
event |
Named error events (config_error, mitm_handshake_error, h2_serve_error, upstream_error, local_file_error) |
client |
Client IP:port |
method |
HTTP method |
scheme |
http or https |
host |
Target hostname |
path |
URL path |
status |
Response status code |
source |
local (map_local) or upstream |
duration_ms |
Request duration in milliseconds |
proto |
http/1.1 or h2 |
error |
Error message |
Start with -dump-traffic to log request/response headers at debug level after all transforms are applied. Authorization, Cookie, and Set-Cookie headers are redacted. Body snippets (up to -max-body-bytes) are also logged, while full response bodies are still forwarded to clients.
jeltz -log-level debug -dump-traffic| Path | Contents |
|---|---|
~/.config/jeltz/config.yaml |
Default config file location |
~/.local/share/jeltz/ca.crt.pem |
Root CA certificate (trust this) |
~/.local/share/jeltz/ca.key.pem |
Root CA private key (keep private) |
~/.local/share/jeltz/ca.p12 |
Root CA PKCS#12 bundle, password jeltz (trust this) |
Locations follow the XDG Base Directory Specification. Override with $XDG_CONFIG_HOME and $XDG_DATA_HOME.
- The outer listener (the port you configure as your HTTP proxy) is HTTP/1.1 only. HTTP/2 is supported only on the decrypted client-to-jeltz leg inside CONNECT tunnels.
- No WebSocket support.
- No transparent/intercepting proxy (requires iptables/TPROXY).
- No web UI.