Skip to content

fabiant7t/jeltz

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

110 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

jeltz

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.


What it does

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

Requirements

  • Go 1.25+

Build

git clone https://github.com/fabiant7t/jeltz
cd jeltz
make build        # produces ./jeltz binary
make test         # run tests
make race         # run tests with race detector

CI runs go test ./... on Linux, macOS, and Windows.


Quick start

# 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).


CA setup

Print the CA certificate path

jeltz ca-path

Print the CA bundle path

jeltz ca-p12-path

Print platform-specific installation instructions

jeltz ca-install-hint

Unknown 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.


CLI flags

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.

TUI controls (-ui)

  • Navigation: j/k (line), Ctrl-d/Ctrl-u (half-page), g/G (top/bottom)
  • Search: / then type query, Enter to apply, Esc to cancel
  • Visibility dialog: v opens per-key visibility settings (space toggle, a all on, n all off)
  • Other: f cycle level filter, s toggle autoscroll, w toggle wrap, c clear, q quit

Configuration

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 rules

Path resolution

  • base_path empty or "." → the directory containing config.yaml (XDG config dir)
  • base_path relative → resolved relative to the XDG config dir
  • base_path absolute → used as-is (less portable)
  • Rule path values that are relative are resolved against base_path

Rule sources

  • rule_sources entries can be:
    • a YAML file (.yaml/.yml)
    • a directory (loaded recursively for .yaml/.yml)
    • a glob pattern (for example rules/**/*.yaml)
  • Relative paths are resolved from the directory containing config.yaml.
  • Loaded rules are appended after inline rules from config.yaml.
  • Rule source files may be either:
    • top-level rules: [...]
    • top-level sequence [...]

Rules

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.

Pipeline order (per request)

  1. Apply matching request header rules (delete then set)
  2. Check redirect rules in file order (first match wins)
  3. If no redirect matched, check map/map_local rules in file order (first match wins)
  4. If no local map matched, check map_remote rules (first match wins)
  5. Proxy to upstream (original or remapped)
  6. Apply matching body_replace rules (replace-all, in file order)
  7. Apply matching response header rules (delete then set)
  8. Apply matched redirect/map/map_local rule's own response ops (after global response rules)

Rule: header

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.


Rule: map_local

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.


Rule: map

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.


Rule: map_remote

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:

  • url must be an absolute URL with scheme and host.
  • Prefix stripping mirrors map_local: with path: ^/v1/, request /v1/users maps to /mirror/users.
  • Original request query is preserved and appended after the mapped URL query.
  • map_local takes precedence if both map_local and map_remote match.

Rule: redirect

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: regex uses Go regex replacement semantics ($1, $2, ... supported in replace).
  • search_mode: literal treats search as an exact string.
  • Redirect is emitted only when the rewrite changes the input URL.
  • Optional response ops are applied after global response header rules.
  • First matching redirect rule wins.

Rule: body_replace

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 filter

Behavior:

  • search_mode: regex uses Go regex replacement semantics ($1, $2, ... supported in replace).
  • search_mode: literal treats search as an exact string.
  • Replacement is applied to all matches.
  • content_type is optional; if set, only responses whose Content-Type header matches the regex are rewritten.

Full example config

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"

Logging

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

Traffic dumping

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

Files and directories

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.


Scope and non-goals (v1)

  • 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.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors