Zicade is a small, local forwarding HTTP/HTTPS proxy for Windows with a
loopback web UI. It listens on 127.0.0.1, forwards plain HTTP (absolute-form →
origin-form) and tunnels CONNECT, and can route either directly to origins or
through an upstream corporate proxy — including an SSPI Negotiate (Kerberos/
NTLM) handshake against that upstream. A companion local web server exposes a
JSON API and a browser UI for editing the config, watching live logs, and
reading status.
The workspace is a strict-TDD Cargo workspace:
| Crate | Responsibility |
|---|---|
zicade-config |
Config schema, load/save, validation |
zicade-routing |
PAC parsing + RouteResolver seam |
zicade-auth |
Upstream authenticator + Negotiate handshake state machine |
zicade-win |
The only crate with unsafe/FFI: SSPI + WinHTTP PAC |
zicade-proxy |
The proxy data path (accept loop, forwarding, tunneling, shutdown) |
zicade-observe |
tracing layer → in-memory ring buffer + broadcast for the UI |
zicade-web |
axum JSON API + UI, token-gated mutations |
apps/zicade |
The binary: wires everything together and owns the lifecycle |
-
Target toolchain:
x86_64-pc-windows-gnu(rustup target add x86_64-pc-windows-gnu). The build is pinned to this target via.cargo/config.toml. -
w64devkit on PATH:
C:\Users\<you>\tools\w64devkit\binmust be reachable. The GNU toolchain there provides the linker (gcc→collect2→ld) anddlltool..cargo/config.tomlwires the linker,COMPILER_PATH, and-Cdlltool=...for the target build, but host proc-macro dependencies (e.g.rust-embed) still needdlltool/ldonPATH, so prepend that bin directory before running cargo:export PATH="/c/Users/<you>/tools/w64devkit/bin:$PATH"
-
crates.io reachability: the initial dependency download must reach crates.io. On the corporate network this is the catch: dependency downloads need an off-corp connection (or a working proxy), while the live proxy tests below must run on-corp against the real upstream. Build/vendor deps off-corp first, then run the live suite on-corp.
Releases are automated with release-please. Conventional-commit messages on
main (feat:, fix:, …) drive a rolling release PR that bumps the
workspace version (Cargo.toml, tracked via an x-release-please-version
annotation) and updates CHANGELOG.md. Merging that PR tags the commit, creates
the GitHub Release, and the pipeline builds the Windows zicade.exe and attaches
it to the release.
CI builds the release binary with the MSVC toolchain on windows-latest
(the pinned x86_64-pc-windows-gnu setup in .cargo/config.toml is a
developer-local convenience; the workflow overrides it). The app icon is
embedded either way — see System-tray mode.
Every release ships a setup installer (zicade-<version>-setup.exe) attached to
the GitHub Release, alongside the raw .exe. It targets Windows 10/11 (x64) and
is built in CI with Inno Setup 6.
Run it and follow the wizard.
The wizard first asks who to install for:
- Install for me only — installs under your profile
(
%LOCALAPPDATA%\Programs\Zicade), no administrator rights and no UAC prompt. - Install for all users — installs to
Program Files\Zicade. Choosing this triggers a UAC elevation prompt (only then); required for the Windows-service option below.
A Select Additional Tasks page offers two off-by-default ways to start Zicade automatically:
- Start on sign-in — adds a
Run-key entry (per-userHKCUor all-usersHKLM, matching the scope) so Zicade launches at login. Because it starts with no subcommand, it runs in system-tray mode (a console window may flash briefly before it self-hides). - Run as a Windows service — registers the auto-start
Windows service via
zicade service install. Only offered for an all-users install (a machine service needs admin).
The uninstaller (in Add or remove programs) removes the exe, Start-Menu
shortcut, the Run-key entry, and — for an all-users install — the service.
Inno Setup supports unattended switches, e.g.:
# All users + auto-start service, no UI:
zicade-<version>-setup.exe /VERYSILENT /ALLUSERS /TASKS="runasservice"
# Just me + start in the tray on sign-in:
zicade-<version>-setup.exe /VERYSILENT /CURRENTUSER /TASKS="startonlogin"
# Uninstall silently:
"%LOCALAPPDATA%\Programs\Zicade\unins000.exe" /VERYSILENTDevelopers can build the installer locally by opening
installer/zicade.iss in the Inno Setup IDE, or from a
shell after a release build:
iscc /DMyAppVersion=0.0.0 installer\zicade.isscargo run -p zicadeOn startup the binary:
- Resolves the config path (first CLI argument, else the default below).
- Loads the config, or writes the defaults on first run.
- Installs the tracing subscriber (level/format from
logging). - Binds the proxy and the web server on loopback, then serves until
Ctrl-C(graceful shutdown drains in-flight connections).
Pass an explicit config path if you like:
cargo run -p zicade -- C:\path\to\config.json- Proxy:
config.listen— default127.0.0.1:3129. - Web UI / API: the proxy port + 1, same host — default
127.0.0.1:3130.
Point your browser or system proxy at the proxy address; open the web address in a browser for the UI.
%LOCALAPPDATA%\Zicade\config.json (created with defaults on first run).
Zicade can run under the Service Control Manager (SCM) instead of a console. Console mode remains the default and is unchanged.
Install, then start (both require an elevated / Administrator prompt):
zicade service install # register (auto-start, own-process)
zicade service install --name MyZicade # ...under a custom service name
sc start Zicade # start now (or reboot: it is auto-start)Uninstall:
sc stop Zicade # optional; stopping maps to graceful shutdown
zicade service uninstall # delete the service (add --name to match install)Notes:
- The installed service runs
zicade service runfrom the current executable path and serves the proxy + web UI exactly as console mode does. - Stopping the service (
sc stop,services.msc, or system shutdown) sendsSERVICE_CONTROL_STOP/SHUTDOWN, which is mapped to the app's existing graceful shutdown (in-flight connections drain, same as Ctrl-C). - Without Administrator, install/uninstall fail cleanly with an access-denied message (no panic).
When zicade.exe is launched with no subcommand, it chooses between console
and tray mode by how it was started:
- Double-clicked in Explorer — the process owns a freshly-created console
(detected via
GetConsoleProcessListreturning1). Zicade hides that console and runs with a system-tray (notification-area) icon, serving the proxy + web UI exactly as console mode does. - Run as
zicadefrom an existing cmd/PowerShell — the console is shared (process count> 1), so it stays in console mode as before.
Explicit subcommands never enter tray mode: zicade run, zicade service …, and
zicade --help always keep their current behavior regardless of how they were
launched.
The tray icon's right-click menu has exactly two items:
- Open WebUI — opens the web UI (
http://127.0.0.1:3130/, i.e. the proxy port + 1 from the loaded config) in the default browser. - Close — triggers the same graceful shutdown as Ctrl-C and exits.
Validation runs at startup and fails fast with a clear message: ports must
be non-zero, upstream/pac sections are required for their modes, basic auth
requires a username, negotiate auth is only accepted on Windows, and an enabled
corpNetwork requires a non-empty dnsSuffixes and a non-zero pollSeconds.
Optional and disabled by default (absent = off; older configs are
unaffected). When enabled with mode = "pac" or "upstream", a background
monitor decides whether the machine is on the corporate network by matching the
active adapters' connection-specific DNS suffixes against dnsSuffixes
(case-insensitive, on a dot boundary — dy.droot.org matches droot.org):
- On the corporate network → the configured routing (PAC/upstream) applies.
- Off it → the proxy routes direct, so it keeps working when you disconnect from corp instead of failing every request against an unreachable gateway/PAC.
Detection is event-driven (it reacts to Windows address-change notifications)
with pollSeconds as a re-check fallback. The on-corp routing is rebuilt on each
reconnect, so source = "auto" re-reads the system PAC after you rejoin the
network (even if the app started off-corp). The web status panel shows the
current on-corp state, and routing swaps live with no restart. Off-Windows,
or if adapter enumeration fails, detection is unavailable and the configured
routing applies unchanged (the feature is purely additive). Enabling the gate
from the UI when it was off at startup requires a restart; changing suffixes or
toggling it while it is already running takes effect immediately.
- URL:
http://127.0.0.1:3130/(proxy port + 1). - A clean, theme-aware control panel (vendored Pico CSS v2.1.1, embedded and
served same-origin — no external CDN, so it works fully offline). The form
exposes every config field (listen, routing mode, upstream + its auth, PAC
- its auth, corporate-network detection, logging, and the web-auth toggle); the
upstream/PAC/corp-network subsections show or hide based on the selected routing
mode. Edits are serialized back to
the same JSON schema the backend validates, so invalid input returns a
4xxshown inline.
- its auth, corporate-network detection, logging, and the web-auth toggle); the
upstream/PAC/corp-network subsections show or hide based on the selected routing
mode. Edits are serialized back to
the same JSON schema the backend validates, so invalid input returns a
- Read-only endpoints (
GET /api/config,GET /api/status,GET /events/logsSSE) are open on loopback. - A live status panel shows the active routing mode, the corporate-network
on-corp state (when the gate is enabled), listen address, total
and failed request counts, active connections, and current in/out throughput
with a 60-second inline-SVG sparkline. It is driven by a
GET /events/metricsSSE stream (oneStatusSnapshotper second; the client derives byte/s from the cumulativebytes_in/bytes_outdeltas) and shows a reachable/unreachable dot. Throughput currently reflectsCONNECT-tunnelled (HTTPS) bytes, counted from the tunnel's bidirectional copy. Routing/auth edits saved from the form apply to the running proxy without a restart; a listen host/port change still needs a restart.
-
Mutations (
PUT /api/config) are gated by theweb.authRequiredflag in the config'swebsection:"web": { "authRequired": false }
-
Default
false— auth is normally unneeded because the web server binds to loopback (127.0.0.1) only, so nothing off-box can reach it. Configs without awebsection still load and default to off. -
Set
web.authRequired = trueto require the local UI token in theX-Zicade-Tokenheader on every mutation (missing/wrong token →401). That custom-header requirement also doubles as CSRF protection (a cross-site request cannot set an arbitrary header). Read-only endpoints stay open on loopback either way. -
Token file:
%LOCALAPPDATA%\Zicade\ui-token, created on first run (32 hex chars). When auth is on, read it from that file and send it asX-Zicade-Token(the UI has a token field for this).
export PATH="/c/Users/<you>/tools/w64devkit/bin:$PATH"
cargo test # full workspace suite (deterministic, no network)The default suite is hermetic: no real upstream network, ephemeral loopback ports, fake origins/backends.
The live tests are gated behind an env var and are intended for the
domain-joined Windows machine, on the corporate network, pointing at the real
wp8080 upstream + PAC:
ZICADE_LIVE_PROXY=1 cargo testRun these only on-corp; off-target they are skipped. Verified green against the
real Skyhigh/McAfee Secure Web Gateway at wp8080:8080: the multi-leg
407 -> NTLM -> 200 handshake, HTTPS via CONNECT through the gateway,
plain-HTTP forwarding, and a soak that holds a flat OS-handle count (no leak).
The live PAC check exercises the source = "auto" discovery path (per-user
AutoConfigURL -> WPAD -> static proxy). It is tolerant of hosts with nothing
configured (a common corporate setup that pushes routing via GPO): discovery
maps "no config" to DIRECT, so it logs the real decision rather than failing.
Creating and deleting a real service needs Administrator and a live SCM, so it
is gated behind ZICADE_SERVICE_IT=1 and #[ignore]d (the default suite stays
hermetic and admin-free). From an elevated shell:
ZICADE_SERVICE_IT=1 cargo test -p zicade-win --test service_it -- --ignoredIt installs then immediately deletes a throwaway ZicadeServiceItTest service.
- PAC data-path routing is wired. In
mode = "pac"the proxy resolves each request through WinHTTP and connects DIRECT or through the resolved upstream (applyingrouting.pac.auth, LESSON-6).source = "auto"discovers the effective proxy configuration the way Windows and browsers do: it reads the per-user WinINET/IE settings and applies MSDN precedence — the "Use setup script" address (AutoConfigURL, a PAC URL) first, then WPAD network auto-detect, then a static manual proxy (honouring its bypass list:<local>, exact and*-wildcard hosts), and finally DIRECT when nothing is configured. This means a PAC configured only underHKCU\...\Internet Settings\ AutoConfigURLis now honoured (previouslyautodid WPAD auto-detect only and failed withERROR_WINHTTP_UNABLE_TO_DOWNLOAD_SCRIPTon hosts with no WPAD server).source = "url"(or"file") still points at a PAC explicitly.failPolicygoverns resolution failures:"direct"falls back to a DIRECT connection,"error"fails the request with502. PAC results are not cached (each request re-resolves). - Upstream connections are authenticated per request, not pooled. Because
Negotiate/NTLM authenticates the TCP connection (not the request), each
HTTP-forward request opens a fresh upstream connection, completes the multi-leg
407 handshake, and tears it down. This is an intentional, correct, leak-free
design (LESSON-7): the per-request connect/auth/teardown holds a flat OS-handle
steady state, locked in by regression tests. Reusing an authenticated upstream
connection across requests on the same client keep-alive connection would save
the handshake cost, but is deliberately deferred — it adds per-connection
stream state, mid-reuse reconnect/re-auth, and keep-alive framing complexity
with real risk to the handle-stability guarantee, for a latency win that does
not justify it.
CONNECTtunnels are inherently per-connection and unaffected. negotiateis Windows-only. Off-Windows, config validation rejects it; if a Negotiate authenticator is somehow constructed off-target, it errors at handshake time rather than at startup.negotiatedefaults to the NTLM SSPI package, not SPNEGO/Kerberos. The target corporate gateway (Skyhigh/McAfee) offersNegotiate/NTLM/Basicbut does not accept SPNEGO, and has no Kerberos SPN for the proxy appliance. The SSPINegotiatepackage's raw-NTLM fallback also cannot complete against it (SEC_E_INVALID_TOKENon the challenge leg). So the SSPI package defaults to NTLM, which completes the standard three-leg handshake; the gateway accepts the token under theNegotiatescheme. Environments with a Kerberos SPN can opt into Kerberos SSO by settingauth.package = "negotiate"(see config).
auth.mode selects how Zicade authenticates to the upstream proxy:
none— no proxy authentication.basic— sendsProxy-Authorization: Basic base64(user:pass)preemptively; requires a non-emptyauth.username(validated at startup). A407after the credential was sent fails cleanly (no retry loop).negotiate— Windows SSPI.auth.packagepicks the security package:"ntlm"(default, required by the target gateway) or"negotiate"(SPNEGO/Kerberos, for gateways with a registered SPN).
{ "listen": { "host": "127.0.0.1", "port": 3129 }, "routing": { "mode": "direct", // "direct" | "upstream" | "pac" "upstream": { // required when mode = "upstream" "host": "wp8080", "port": 8080, "auth": { "mode": "negotiate", // "none" | "basic" | "negotiate" "package": "ntlm", // negotiate only: "ntlm" (default) | "negotiate" "username": null, "password": null // username required when mode = "basic" } }, "pac": { // required when mode = "pac" "source": "auto", // "auto" | "file" | "url" "path": null, "url": null, "failPolicy": "error", // "error" | "direct" "auth": { "mode": "negotiate" } // inherited by PAC-selected upstreams }, "corpNetwork": { // optional; gates pac/upstream on the corp network "enabled": true, "dnsSuffixes": ["droot.org"], // required when enabled; matched dot-boundary, case-insensitive "pollSeconds": 30 // re-check fallback interval (default 30) } }, "logging": { "level": "info", "format": "json" } }