GreenTunnel
CLAUDE.md4.9k starsChanged 3 months agoMITView on GitHub
Ground-up TypeScript rewrite of GreenTunnel: a local proxy that bypasses DPI censorship by splitting the TLS ClientHello so the SNI never appears whole in a single packet, and by resolving hostnames over encrypted DNS. v3 reuses v2's techniques, not its code. v3 is the repository now — it was developed in a v3/ subdirectory and promoted to the root, replacing v2's src/, bin/ and gui/. Those are gone from the working tree; read them at the v2.0.3 tag (git show…
- Deletes or force-pushes
- Installs packages
- Commits and pushes
We split this CLAUDE.md into the rules it gives an agent
6 rules. About 0 are common, 1 is distinctive, and 5 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
5
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 19
Run everything from the repository root.
- Very specific: line 53
Default settings are startOnLaunch: true + manageSystemProxy: true, so launching the app points macOS/Windows/GNOME at it.
- Very specific: line 67
Add --remote-debugging-port=9333 and curl -s localhost:9333/json/list to confirm the renderer loaded without needing to look at the screen.
- Very specific: line 427
HTTP and HTTPS were snapshotted as one reading: Only -getwebproxy was read and mirrored to both on restore, clobbering any service …
- Very specific: line 727
not: stop electron-builder from _resolving_ a publish target, and the GH_TOKEN the step passed for no reason made it resolve a GitHub one.
- Very specific: line 769
20 May 2026: must also explicitly select at least one allowed action.
≈ “Common” is an estimate: at least 1 in 5 of the 25,680 CLAUDE.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
GreenTunnel v3
Ground-up TypeScript rewrite of GreenTunnel: a local proxy that bypasses DPI censorship by splitting the TLS ClientHello so the SNI never appears whole in a single packet, and by resolving hostnames over encrypted DNS.
v3 reuses v2's techniques, not its code. v3 is the repository now — it was
developed in a v3/ subdirectory and promoted to the root, replacing v2's
src/, bin/ and gui/. Those are gone from the working tree; read them at the
v2.0.3 tag (git show v2.0.3:src/proxy.js) and treat them as reference
material, not as something to keep in sync.
Status: working base. The engine, CLI and desktop app all build, pass lint and tests, and have been exercised end-to-end. Features and packaging are the next phase — see Roadmap.
Commands
Run everything from the repository root.
npm install # workspaces: packages/*, apps/*
npm run build # tsc --build (cli) + electron-vite build (desktop)
npm run typecheck # all three TS projects
npm run lint # eslint, type-aware
npm test # vitest
npm run check # typecheck + lint + test
npm run dev # electron-vite dev — the desktop app, with HMR
npm run dev:cli # build the CLI and run it
npm run clean # drop dist/, out/, release/, build infoPer package:
npm run build --workspace packages/cli # the engine and the CLI
npm run package --workspace apps/desktop # electron-builder, current OS
npm run package:mac --workspace apps/desktop # or :win / :linuxRunning the CLI
node packages/cli/dist/main.js --help
node packages/cli/dist/main.js --port 8080 --no-system-proxy⚠️ npm run dev changes your real system proxy
Default settings are startOnLaunch: true + manageSystemProxy: true, so
launching the app points macOS/Windows/GNOME at it. For iterating on UI or
main-process code, use a throwaway profile instead:
mkdir -p /tmp/gt-profile
cat > /tmp/gt-profile/settings.json <<'JSON'
{ "startOnLaunch": false, "manageSystemProxy": false, "launchAtLogin": false,
"port": 18500, "dnsMode": "doh", "fragmentSize": 40, "tlsRecords": false }
JSON
npm run build --workspace apps/desktop
./node_modules/.bin/electron apps/desktop --user-data-dir=/tmp/gt-profileAdd --remote-debugging-port=9333 and curl -s localhost:9333/json/list to
confirm the renderer loaded without needing to look at the screen.
Layout
├── packages/cli/ green-tunnel — the **only** published package: the engine
│ (src/core) plus the `gt` binary
├── apps/desktop/ green-tunnel-desktop — Electron 43, private, never published
├── assets/ README artwork (logo, demo gif, screenshot)
├── Dockerfile two-stage; packages/cli only, never apps/desktop
└── .github/workflows/ test.yml (every push) · publish.yml (v* tags)packages/cli/src/core — the engine
Its own package (@green-tunnel/core) for exactly one release cycle, and never
published under that name. See
One package, not two for why it was folded back
in. Nothing in here may import from outside it — ESLint enforces that, since a
directory cannot.
src/core/
├── index.ts public API surface
├── types.ts every settings/event/stat type
├── config.ts defaults + resolveSettings + assertValidSettings
├── errors.ts typed errors (DnsError, ConnectError, …)
├── logger.ts levelled, scoped, pluggable sink (replaces `debug`)
├── proxy/
│ ├── index.ts Proxy — EventEmitter, start/stop, stats
│ ├── connection.ts read the first head, route CONNECT vs plain
│ ├── connect-tunnel.ts HTTPS: fragment the ClientHello ← the whole point
│ ├── plain-tunnel.ts HTTP: relay + rewrite request heads
│ ├── pipe.ts bidirectional pipeline with teardown
│ └── context.ts per-connection dependency bundle
├── tls/fragment.ts ClientHello detection + the two split strategies
├── http/
│ ├── head.ts request-line/header parse, origin-form, authority
│ └── rewriter.ts framing-aware keep-alive head rewriter
├── dns/
│ ├── resolver.ts TTL cache + family preference + in-flight dedupe
│ ├── doh.ts dot.ts plain.ts decode.ts
├── net/socket.ts connect with timeout, backpressure-aware write
└── system-proxy/ darwin.ts linux.ts windows.ts + snapshot/restore
recovery.ts — snapshot persisted to disk so a killed
process's proxy can still be undone next launchThe four files beside it are the CLI proper, and are the only things in the package the engine may not reach:
src/
├── index.ts library surface — re-exports src/core wholesale, plus VERSION
├── main.ts the `gt` binary: parse, run, handle signals
├── options.ts parseArgs → ProxyOptions, HELP_TEXT, UsageError
├── ui.ts the banner and status output
└── version.ts read once, at build timeapps/desktop
src/
├── main/ index.ts (wiring) · tunnel-service.ts (owns engine + OS proxy)
│ window.ts · tray.ts · ipc.ts · settings.ts · json-store.ts
│ log-buffer.ts (the ring) · logs-window.ts (the second window)
│ advocacy.ts (when to ask for a star, and whether to again)
├── preload/ the only bridge — one API object, shared by both pages
├── renderer/ vanilla TS + Vite, no framework. Two pages: index.html (the
│ column) and logs.html (the log panel). advocacy.ts holds the
│ two <dialog> sheets: share, and the star prompt
└── shared/ types.ts + ipc.ts + share.ts, imported by all threeArchitecture
browser → CONNECT host:443 → Proxy
├ dns.resolve(host) DoH / DoT / plain, TTL-cached
├ net.connect(ip:443) with timeout
├ 200 Connection Established
├ fragmentClientHello() ← DPI evasion happens here
└ pipeline() both waysTwo deliberate separations, both different from v2:
Proxynever touches the OS proxy.SystemProxyis independent, so the engine is testable without root and a crashed engine cannot strand the machine pointed at a dead port. The CLI andTunnelServicecompose the two.TunnelServiceowns all desktop state and publishes one immutableAppState. Tray and window both render from it; nothing is hand-synced.
Conventions
- Native ESM. Relative imports carry a
.jsextension even in.tsfiles — the standard TS-ESM convention (./types.jsresolvestypes.ts). #privatefields, notprivate.erasableSyntaxOnlyis on, so no enums, no namespaces, no parameter properties.- Strict everything:
noUncheckedIndexedAccess,exactOptionalPropertyTypes,noPropertyAccessFromIndexSignature. Index-signature reads useinput['key'], notinput.key. - Errors are thrown, not logged-and-swallowed. That was v2's defining bug.
- Comments explain why, especially where the code corrects a v2 mistake.
Pinned versions, and why
| What | Version | Why not newer |
| Electron | 43.2.0 (exact) | Latest stable. Electron has no LTS channel — it supports the newest 3 majors. Bundles Node 24. No caret, see below. |
| Node | 24 LTS (Krypton) | engines: >=24. Node 26 is Current, not LTS until Oct 2026. |
| TypeScript | 6.0.3 | 7.0.2 is out (native port) but typescript-eslint@8.65 peers >=4.8.4 <6.1.0. Revisit when typescript-eslint ships TS 7 support. |
| Vite | 7.3.6 | Vite 8 is out but electron-vite@5 peers ^5 || ^6 || ^7. |
Electron's version is written "43.2.0", not "^43.2.0", and that is not a
style preference. electron-builder downloads a platform-specific binary for one
exact release, so it refuses a range outright — Electron version "^43.2.0" is a
range, not a fixed version. It can fall back to reading the installed version
out of node_modules, but not here: npm hoists electron to the workspace
root, and electron-builder only looks under apps/desktop. So the range never
resolved and packaging failed before it started. Keep the caret off.
Runtime dependencies are deliberately tiny: green-tunnel needs only
dns-packet and lru-cache (both for the engine), and the desktop app has
zero — it bundles. A clean npm i green-tunnel installs four packages
including transitives. parseArgs,
styleText, dns.Resolver and fetch come from Node itself, replacing v2's
yargs, chalk, clear, ora, debug, dns-socket, dns-over-tls,
validator and winreg.
Electron specifics
- Package is
"type": "module"; main runs as ESM. - Preload must be CommonJS —
sandbox: trueforbids ESM preloads. It is built toout/preload/index.cjsviaoutput.format: 'cjs'. Do not "fix" this. - The renderer is fully locked down:
sandbox,contextIsolation,nodeIntegration: false, a CSP meta tag, navigation blocked, andshell.openExternalrestricted to an origin allowlist. apps/desktopdeclares no runtimedependencies— the engine and its deps are bundled intoout/main/index.js(172 kB). That keeps electron-builder away from workspace symlinks entirely, and it is why the packaged.appcontains nonode_modulesat all. The import isfrom 'green-tunnel', the workspace package: only itssrc/index.tsis reachable that way and that file does not importmain.ts, so none of the terminal code comes along.resources/ships outside the asar (extraResources) sonativeImage.createFromPathfinds the@2xtray variants.- Tray menu items use
click, neverrole— arolemakes Electron ignoreclick, which would skip the system-proxy restore on quit. - A sandboxed preload cannot read
process, in either spelling. A bareprocessis rewritten by Vite intorequire("node:process"), which collides with the binding the sandbox already injects ("Identifier 'process' has already been declared");globalThis.processisundefined, because that binding is scoped to the preload wrapper and not to the global object. Either way the preload throws beforeexposeInMainWorld, sowindow.greenTunnelis undefined and every page dies on its first call — with the only clue in the renderer console, not on stderr. Facts the main process knows go into the window's URL instead (logs.html?platform=darwin). window.close()is ignored for a window the page did not open. Chromium's rule, and it applies to aBrowserWindowthe main process created: the call is a silent no-op, and because nocloseevent fires, anything hanging off it — such as saving the window's bounds — never runs either. Route it through IPC (IPC.closeLogs).
The log panel
main/log-buffer.ts is a bounded ring (5000 entries) wired up as the engine
logger's sink. It is deliberately independent of the engine: changing an
engine setting restarts the Proxy, and history that vanished with it would
lose exactly the lines that explain why the user was changing the setting.
Records are flushed to the window in ~120 ms batches, so a trace-level flood is
not one IPC message per line, and are dropped entirely while it is closed.
logLevel is an AppSettings field, so it survives a restart, but it is not
in affectsEngine — applySettings calls logger.setLevel instead, which is
why turning the level up mid-session never drops a live connection. That works
only because Logger shares one mutable state object with its children; copying
the level into child() (as v3.0 did) leaves a child stuck at whatever the level
was when it was created.
The page (renderer/logs.html + src/logs.ts) is a virtual list: only the
rows in view exist in the DOM — 1600 lines rendered 31 elements — and a row's
position is index × 18px. That arithmetic is exact only while every row is the
same height, so nothing in the list may wrap: long lines scroll sideways, and
an error's stack is flattened into one row per frame at model-build time rather
than growing the row it belongs to. --row in logs.css and ROW_HEIGHT in
logs.ts are the same number and must stay that way.
The window is sized by its content
The desktop window is v2's: a 340 px-wide frameless column, dark only, with the tunnel mark inside a circular power button. Everything beyond the on/off switch lives behind the Advanced disclosure at the bottom.
Its height is never persisted and never set by hand. The renderer measures
#app with a ResizeObserver and calls resizeContent(px); setContentHeight
in main/window.ts animates the window to it. That one rule covers the advanced
panel, an error message appearing, and anything else that reflows.
The advanced panel is the last element in the document, and that is load bearing. Nothing in the page animates a height — the window's bottom edge is the only clip, so growing the window is the reveal, and shrinking it is the reverse. Animate the panel's height as well and the two curves disagree for a few frames, which tears the bottom row. Opening therefore un-hides the panel and lets the observer do the rest; closing asks for the collapsed height first and unmounts the panel only after the animation, or a void opens under the note.
WindowState holds { x, y, advancedOpen } — no size. advancedOpen is UI
chrome, deliberately not an AppSettings field: writing a setting restarts the
tunnel, and a disclosure triangle must not drop live connections. For the same
reason TunnelService.applySettings restarts only when a key the engine
actually reads has changed (affectsEngine), so "Launch at login" is free.
The panel starts closed on a fresh install, and the user's own choice is
persisted to window-state.json and restored at boot — both directions, so
closing it sticks too. The default is DEFAULT_UI_STATE in shared/types.ts,
shared by DEFAULT_WINDOW_STATE and the renderer rather than spelled out in
each: the renderer's copy is what the page is laid out against for the moment
before getUiState() resolves, so a disagreement between the two would show as
a panel that flickers open, or a first frame measured at the wrong height.
Asking for a star, without becoming nagware
Two popups, both <dialog> + showModal(): Share Green Tunnel (X, Telegram,
WhatsApp), reachable any time from the Share ↗ link at the foot of the advanced
panel, and the "Enjoying Green Tunnel?" prompt that appears on its own.
They are dialogs and not cards in the column because of the rule above. A
modal renders in the top layer, so it is out of flow and never enters the height
#app is measured at — a sheet laid out in the column would move the window's
bottom edge, which is the one thing that must only ever mean "the panel is
opening". The Escape handler in renderer/src/main.ts stands down while a sheet
is open, or one key press would close the sheet and hide the window behind it.
The schedule lives in main/advocacy.ts and is stored in advocacy.json beside
the settings:
- Three asks, ever. The delays are relative to the previous one — 3 days, then 27, then 60 — which lands on day 3 / day 30 / day 90 for a regular user. Absolute deadlines were the obvious spelling and the wrong one: someone who installs the app and first opens it three months later would have all three come due at once and get every prompt inside a single week.
- Star or Share retires it permanently (
helped), as does "Don't show again" (dismissed). "Later" only spends the current slot. - The slot is spent when the prompt is shown, not when it is scheduled, so a window hidden inside the 2.5 s settle keeps its chance.
createAdvocacyStorewrites the file on first launch. It has to:firstRunAtdefaults to now, so without that write it would be "now" again on every launch and the first prompt would never come due.
Share copy and the three target URLs are in shared/share.ts, and
ALLOWED_EXTERNAL_ORIGINS is built from them — a target added there cannot
silently fail to open, which is what openExternal would otherwise do. Keep the
message under ~250 characters: X counts the link as 23 whatever its length.
One package, not two — and why
v3 was built as @green-tunnel/core + green-tunnel, and that split was undone
before either shipped. The engine now lives at packages/cli/src/core and
green-tunnel is the only published package.
The reason is that the split was never load bearing. apps/desktop already
treated the engine as an internal module — it depended on it as a devDependency
and bundled it, so it never needed a registry package. And packages/cli/src/index.ts
was nothing but export * from '@green-tunnel/core', so library users already got
the whole engine from green-tunnel, the name v2 published under for ten years.
Publishing core separately was a second distribution channel for identical code,
pinned to the same version, gated on owning an npm organisation that did not
exist — which is exactly what broke the first v3 release attempt (see
Release and distribution).
What the merge deleted: an npm org to create and hold, an exact-version dependency edge between two packages, a publish-ordering constraint, a two-version tag check, one of three tsconfigs, and six of the Dockerfile's thirteen workspace lines.
What it cost: the engine/CLI boundary was structural — a separate package simply
could not import the CLI — and a directory guarantees nothing. So it is enforced
by ESLint instead, a no-restricted-imports block over packages/cli/src/core/**
that forbids the four CLI files by name. If you add a fifth file beside main.ts,
add it to that list. npm run lint is in npm run check, so it is not optional.
Two tsconfigs in packages/cli
This split moved with the engine rather than disappearing, because the problem it
solves did: the build must not emit *.test.ts into dist/, but excluding tests
outright leaves them in no project at all and ESLint's type-aware rules fail with
"not found by the project service". So packages/cli/tsconfig.json is the
editor/ESLint view (includes tests, noEmit) and tsconfig.build.json is what
emits dist/. The root project reference points at tsconfig.build.json, and
so must tsc --build anywhere it is spelled out — the workflows and the Dockerfile
all say packages/cli/tsconfig.build.json.
tsBuildInfoFile sits outside dist/, because files: ["dist"] would
otherwise publish 42 kB of incremental-build metadata to npm.
v2 bugs fixed here
Worth knowing about, because most are easy to reintroduce:
BaseDNS.lookupcaught every error and returnedundefined; the socket layer passed that tonet.connect, silently dialling localhost.createConnectionnever rejected — a refused or black-holed upstream hung the client socket forever. No timeout anywhere.write()return values ignored: no backpressure, unbounded buffering.- The HTTP handler re-parsed every chunk as a request, so any body starting
with a method-like word (or any binary upload) was corrupted. v3 tracks
Content-Length/chunked framing instead. Covered byrewriter.test.ts. HTTPResponse's constructor calledthis._parseRequest, which did not exist — it threw whenever it was given raw data. PlusstatusMessgae.bufferToChunksre-framed TLS records and then chopped the result bychunkSize, cutting records at arbitrary boundaries; trailing coalesced records were mangled.- Fragmentation was applied to the first packet whatever it was, breaking non-TLS CONNECT tunnels. v3 checks for a real ClientHello.
- DNS cache ignored record TTLs and never expired;
cacheSizewas read from the global config, not the instance. - DoH spoke the Cloudflare/Google JSON API, so most DoH servers failed. v3 uses RFC 8484 wire format.
dns-socketkept one module-level socket forever and readanswers[0].dataunguarded → crash on an empty answer.- macOS system proxy only touched the one service on the default route, and
interpolated its name into
sh -c "...". v3 usesexecFileargument arrays across every enabled service. unsetProxyblanket-disabled the proxy instead of restoring what was there, wiping any pre-existing corporate/dev proxy. v3 snapshots first.- The GUI ran
nodeIntegration: truewithcontextIsolation: false. - The CLI logged
uncaughtExceptionand carried on with a half-dead proxy while the OS still pointed at it.
The "stranded proxy" class — v3's own bugs, found after the first pass
These are the ones that actually bricked a real machine's networking, and every
one is easy to reintroduce. The failure mode is nasty because it is invisible:
macOS publishes only the highest-ranked active service's proxy config to
State:/Network/Global/Proxies, which is what every app reads. A leftover proxy
on an unplugged dongle sits dormant, then captures all traffic the moment that
dongle reappears — while System Settings → Wi-Fi → Proxies looks perfectly
clean, because the stale setting lives on a different service. It looks exactly
like "the internet is broken", and a reboot appears to fix it only because the
interface comes back down.
snapshot()andapply()enumerated the network services separately. Any service appearing between the two calls — a USB-C ethernet dongle, an iPhone tether, a VPN connecting — got proxied with no snapshot entry, sorestore()never touched it. Permanent. Fixed by makingapplytake the snapshot and touch exactly its targets, so the two sets are identical by construction. Never re-enumerate insideapplyorrestore.- The damage was self-perpetuating. A stranded
127.0.0.1:<port>was snapshotted by the next run as the user's original proxy and dutifully restored on exit, forever.disown()insystem-proxy/index.tsnow rewrites any entry pointing at loopback on the port we are about to bind into "was off". restore()aborted on the first failure. A plainfor … awaitloop threw on the first service that refused a write — macOS lists VPN services that do — abandoning every service after it.settle()now attempts all of them and reports the failures together.- The bypass list was applied and never restored, so GreenTunnel's
localhost 127.0.0.1 ::1 *.localgot burned into all ten services permanently. Snapshots now record it; restore puts it back (Emptyclears). - HTTP and HTTPS were snapshotted as one reading. Only
-getwebproxywas read and mirrored to both on restore, clobbering any service configured with just one of the two. Entries now carry a separatesecureendpoint. - No crash-safety whatsoever.
SIGKILL, a force-quit or anpm run devreload left the proxy set with nothing recording what had changed, so no later run could fix it.enable()now persists the snapshot before touching the OS, andrecoverSystemProxy()— called at startup by both the CLI and the desktop app — undoes an orphan left by a dead PID. The file is only deleted after a successful restore, so a failed one is retried next launch. - CLI:
process.oncefor signals. A second Ctrl-C while "restoring…" was on screen hit Node's default SIGINT action and killed the process mid-restore. Signals now useprocess.onand absorb repeats.SIGHUP— closing the terminal — was not handled at all. - Desktop: Electron does not turn a signal into
before-quit. It just dies, sonpm run devreloads andkillskipped teardown entirely. Signals are now routed throughapp.quit(). Teardown also has a 15 s timeout so a wedgednetworksetupcannot block quit forever.
Verified
Actually run, not assumed:
tsc --build, desktoptypecheck,eslint,vitest(33 tests) — all clean.electron-vite build— main/preload/renderer all emit.- Proxied
curlthrough the CLI: HTTPS CONNECT, a second HTTPS host, plain HTTP absolute-form rewrite, a POST whose body beginsGET http://…(intact), and--https-onlyreturning 403 for plain HTTP. - Fragmentation:
--tls-records, TCP split with--fragment-delay, and--no-fragment. - DNS: DoH against Cloudflare / Google / AdGuard, DoT against 1.1.1.1 / 8.8.8.8 / 9.9.9.9, plain against system resolvers and 8.8.8.8.
- Electron boots and the renderer loads (checked over the DevTools protocol).
- The window, screenshotted over the DevTools protocol in every state: off,
connecting (spinner arc), on, and a forced
EACCESfailure. The advanced panel opens, closes, survives a relaunch, and clips cleanly against the window edge mid-collapse with no void.curlthrough the running app returned 200 over both HTTPS CONNECT and plain HTTP. TogglingstartOnLaunchleftstats.startedAtuntouched; changingfragmentSizerestarted the tunnel. SystemProxyon macOS, against realnetworksetupacross ten services (Wi-Fi, two USB-C ethernet dongles, Thunderbolt Bridge, three iPhone-USB services, Tailscale, V2BOX, Shadowrocket):- full enable/disable cycle restores every service;
SIGINT,SIGTERM,SIGHUPand a triple-SIGINTall tear down cleanly;kill -9strands the proxy, and the next run detects and undoes it;- a seeded corporate config (HTTP on
10.0.0.9:3128, HTTPS recorded but switched off on:3129, bypass*.corp internal.example) comes back byte-for-byte after a full cycle; - SOCKS is never written, so another local proxy's config survives untouched.
- The log panel, driven over the DevTools protocol against a real run with
3000 connections pushed through the proxy:
- 1601 lines rendered 31 DOM rows, canvas height exactly
lines × 18; - tailing follows new lines, and scrolling up raises "Jump to latest";
- filtering (
1,200 of 1,601 lines), no-match and cleared empty states; - Copy put correctly formatted lines on the clipboard;
- overflow past the ring keeps the last 5000 entries and says so;
- at 520 px wide every row is still 18 px and the long lines scroll sideways;
- the level change wrote
settings.json, took effect immediately, and leftstats.startedAtuntouched — no restart.Offcaptured nothing at all; - history survived an engine restart (
fragmentSize), which loggedsettings changed; restarting the tunnel/stopped/listening on …; - Escape closes the window, and its bounds come back exactly on reopen.
- 1601 lines rendered 31 DOM rows, canvas height exactly
electron-builderon macOS, run locally withCSC_IDENTITY_AUTO_DISCOVERY=false: both.dmgs (arm64 and x64) and the two matching-mac.zips all build, and the asar carriesout/main/index.js,out/preload/index.cjsand the renderer, withresources/correctly left outside it. Unsigned — that is what CI produces too. Re-run since withGH_TOKENset, which is what CI does and what used to crash it: four artifacts, nolatest-mac.yml, and noapp-update.ymlin the bundle.electron-builder --linux, cross-built on macOS — fpm and the AppImage tooling are downloaded per-host, so no Docker or Linux box is needed. Both the.AppImageand the.debbuild, the deb's control file carries theHomepage, and the launcher it installs readsStartupWMClass=green-tunnel-desktop, matching the app_id Electron derives from the manifestname. Extract it withar xandtar -xJf control.tar.xz— and cd somewhere else first:asar extract-file … package.jsonwrites to the working directory, and run fromapps/desktopit overwrites the manifest with the stripped copy from inside the bundle. Built, not run: nothing here says the app works on a Linux desktop, andStartupWMClassin particular is only verifiable there, withxprop WM_CLASSagainst a running window.- Both workflows, on the first v3 tag push.
hadolint, the real Docker build andcurlthrough the container all passed, as did the multi-arch Docker Hub push. Thenpm ci --workspace …filtering in the image was fine. What failed is recorded in full. - The published package, from its own tarball.
npm pack→ install into an empty directory → run the installed binary. 134 files, 67.7 kB, and the only things npm pulled weredns-packet,lru-cacheand one transitive: no@green-tunnel/coreto resolve, and no.tsbuildinfoshipped.gt --versionreported the stamped version, andgt --port 18777 --no-system-proxyproxied bothHTTP/1.1 200 Connection Established(HTTPS CONNECT) andHTTP/1.1 200 OK(plain HTTP) via DoH. That is the whole publish path short of the registry.
Not yet verified — do not assume these work:
SystemProxyon Linux and Windows. Those drivers were updated alongside the macOS one but have still never been executed. Test deliberately.electron-buildersigning and notarization. The--wintarget has now built in CI, and--linuxcross-builds on macOS (see Verified), but neither ships signed.- Windows and Linux at all.
- The log panel's Save button (it opens a native dialog, so it needs a human)
and the tray's Show Logs item — the same
showLogs()the row calls, but never clicked.
Gotchas
npm auditreports 16 highs. Every one is insideelectron-builder's transitive tree (app-builder-lib,@electron/asar,ejs,temp) — all build-time only, none shipped.- Never wrap a programmatic resize in
setResizable(true)/setResizable(false). It is the usual advice for aresizable: falsewindow and it is wrong on macOS: measured on 26, the firstsetContentSizelands and every later one is silently dropped — toggling the style mask pins the window to the size it had at that moment. PlainsetContentSizeworks fine on a non-resizable window. - Reading
window.innerHeightover the DevTools protocol lags the real window size when the app is not frontmost: Chromium throttles the rendering pipeline for a background window, which also delaysResizeObservercallbacks. Trust the pixel dimensions ofPage.captureScreenshotinstead — that comes from the compositor. Costs an hour if you assume the resize code is broken. - Quad9 DoH does not work and cannot: it answers
505 HTTP Version Not Supportedto HTTP/1.1, and Node'sfetchhas no HTTP/2. Use--dns dot --dot-host 9.9.9.9. - Linux system proxy is GSettings-only, so it covers GNOME and relatives.
Terminal tools need
http_proxy/https_proxy, which no process can set for another — the CLI prints the export line under--no-system-proxy. Debugging "the internet is broken" on macOS: never trust the Wi-Fi pane.
networksetup -getwebproxy Wi-Fisays nothing about the other nine services, and the one that matters is whichever is highest innetworksetup -listnetworkserviceorderand currently active. Read the effective config instead:scutil <<< "show State:/Network/Global/Proxies" # what apps actually use networksetup -listnetworkserviceorder # who winsHTTPEnable : 1there with nothing listening onHTTPPortis the bug.- Restoring a macOS proxy takes two writes, not one:
-setwebproxysets host and port and enables it, so a service that had a host recorded but was switched off needs a following-setwebproxystate … off. Otherwise "restore" switches on a proxy the user had deliberately unchecked. - The recovery snapshot lives at
~/Library/Application Support/green-tunnel/system-proxy-snapshot.jsonand is shared by the CLI and the desktop app on purpose — either can clean up after the other. It stores the owning PID, and a live PID's snapshot is left alone so a second instance cannot rip the proxy out from under a running one.
Release and distribution
Inherited from v2 during the promotion, and rewritten rather than copied — v2's
versions assumed src/ + bin/ at the root and a gui/ that installed
green-tunnel from npm.
Cutting a release: push a tag, nothing else
git tag v3.1.0 && git push origin v3.1.0That is the whole procedure. The tag is the only place a release number is
written. Every manifest in the repository sits permanently at 0.0.0, and each
publish job stamps the tag in before it builds — npm pkg set version=…
--workspaces --include-workspace-root, plus an ARG VERSION for the Docker
image. There is no version commit to make, nothing to keep in step across three
manifests and a lockfile, and no way to tag a release whose files disagree with
it, because the files never claim a version at all.
0.0.0 everywhere is therefore correct, not a placeholder someone forgot: a
locally built app or docker build reports 0.0.0 because it is not a
release. gt --version reads the manifest at runtime rather than baking a
constant in at compile time, which is what makes stamping enough.
Three things this deliberately trades away:
npm pkg set, notnpm version— the latter also reconciles the lockfile, which reaches for the registry and fails on a version that is not published yet. Stamping runs afternpm ci, so the install still matches the committed lockfile.- The desktop app depends on the engine as
"green-tunnel": "*", not a pinned version. An exact pin has to be rewritten on every bump, and npm resolves it against the registry rather than the workspace the moment it stops matching —npm versionfails withNo matching version found for green-tunnel@…and leaves the lockfile half-written.*always matches, so the workspace link always wins. - The
versionjob rejects a tag that is notvMAJOR.MINOR.PATCHbefore any other job starts, sov3.1fails in seconds rather than deep insidenpm publish.
A tag that failed before publishing anything can be moved rather than burned:
git tag -f vX.Y.Z && git push -f origin vX.Y.Z. Once npm has accepted a version
that is over — npm forbids republishing the same one, so from then on the only way
forward is a new number. Prerelease tags are allowed by the regex but get no
special handling: they would still move Docker's latest and npm's latest
dist-tag, so treat them as unsupported for now.
The npm page's README is generated, not written
npm reads the README from the package root and never looks above it, so
packages/cli needs one of its own — v2 got this for free by publishing from the
repository root. There is still only one README anybody edits: prepack in
packages/cli runs scripts/npm-readme.js, which rewrites the root README.md
into packages/cli/README.md on the way into the tarball. The generated copy is
gitignored and in .prettierignore; regenerate it by hand with
node scripts/npm-readme.js if you want to look at it.
The rewrite exists because npm resolves relative links against repository.url
plus repository.directory, which here is packages/cli. Left alone,
assets/logo.png would be fetched from packages/cli/assets/logo.png and every
image on the npm page would be broken. So repo-relative targets become absolute:
images to raw.githubusercontent.com (a blob URL serves the HTML page around
the image, not the bytes), everything else to github.com/…/blob/main/…. Anchors,
mailto: and already-absolute URLs are left alone. The current README has five
such links — three assets and two to CLAUDE.md — and the diff against the root
file should never be anything but those rewrites plus the banner comment.
files: ["dist"] does not need README.md added to it: npm always includes the
manifest, the README and a LICENSE if one sits beside them, whatever files
says. That is what takes the tarball from 133 files to 134 — and note the same rule
means the published package still carries no LICENSE, because there is no
packages/cli/LICENSE; the root one is not in the package and npm will not reach
for it.
Dockerfile is two-stage and covers packages/cli only; apps/desktop would
drag ~200 MB of Electron build tooling into a CLI image. Two things about it are
load bearing:
npm ci --workspace packages/clistill validates the whole workspace tree against the lockfile, soapps/desktop/package.jsonhas to be copied in even though it is never installed. Delete thatCOPYand the install fails on a lockfile mismatch, not on a missing directory.- The
CMDis shell form on purpose and uses${VAR:+--flag}. v3 parses arguments withnode:util.parseArgs, where a boolean flag takes no value: v2's--silent "$SILENT"spelling would makeHTTPS_ONLY=falsea usage error rather than "off". Hence the README's warning that setting a boolean variable to anything turns it on. hadolint's DL3025 is ignored for this.
.github/workflows/test.yml — format, npm run check, npm run build,
hadolint, then a real Docker build with curl through the container over both
HTTPS CONNECT and plain HTTP. It sets ELECTRON_SKIP_BINARY_DOWNLOAD=1:
typechecking and bundling need Electron's typings, not its 150 MB binary. The
packaging jobs must not set it.
.github/workflows/publish.yml fires on a v* tag push — v2 used
on: create with a tags: filter, which that event does not support, so it also
fired for branches. Then:
versionextracts the number from the tag, rejects anything that is notvMAJOR.MINOR.PATCH, and hands it to the other three jobs. Every one of them stamps it into the manifests before building — see Cutting a release.- npm: one
npm publish, no ordering to get wrong. No--provenanceflag and noNODE_AUTH_TOKEN: see Trusted publishing below. - Docker Hub: one multi-arch manifest (amd64 + arm64). v2's separate
arm-*tag built from a sed-patched Dockerfile is gone, and so is 32-bit ARM —node:24-alpinehas no armv7 variant. - electron-builder on all three runners with
--publish never, artifacts uploaded, then a draft release. Draft, so a bad build is deleted rather than announced. macOS setsCSC_IDENTITY_AUTO_DISCOVERY: false— there is no Developer ID in CI, and without it electron-builder finds a half-usable identity and fails instead of shipping unsigned.
The desktop job builds the engine (npm run build --workspace packages/cli)
before packaging, and that step is not redundant. electron-vite bundles
green-tunnel by resolving the workspace symlink to its main, i.e.
dist/index.js — a path that does not exist in a fresh npm ci checkout. On a
dev machine dist/ is left over from an earlier build, so the whole job passes
locally and fails only in CI, with a message that names neither the workspace nor
the missing build: [commonjs--resolver] Failed to resolve entry for package.
test.yml never hit this because its npm run build is the root script, which
already runs tsc --build first.
What the first v3 tag taught us, in one place
The first tag push failed five ways, which is worth remembering as a set because only one of them was visible in the error the release actually stopped on:
format:check— seven files. It is the first step intest.yml, so it masked everything after it.- The missing engine build before packaging, above.
- Electron's caret range, see Pinned versions.
404 Not Found - PUT …/@green-tunnel%2fcore— the npm scope did not exist. That 404 reads like a missing package and really means "this scope is not yours";npm org ls green-tunnelsaysScope not foundoutright. Owning the unscopedgreen-tunnelpackage grants no@green-tunnelscope, and it could not be a personal scope either, since the account ishayerisadegh— it would have had to be an npm organisation, created by hand. That is what prompted folding core back in instead, and the whole failure mode no longer exists.- Then, on the second tag:
Tag v… does not match packages/cli— the tag was pushed without bumping any manifest. That check has since been deleted along with the problem: the manifests no longer carry a version for a tag to disagree with. See Cutting a release. - Then, on a later tag:
Cannot cleanup:followed by fourTypeError: Cannot read properties of null (reading 'channel')out ofcomputeChannelNames, after the.apphad already been packaged. Nothing in that message mentions publishing — the cause is that--publish neverdoes not stop electron-builder from resolving a publish target, and theGH_TOKENthe step passed for no reason made it resolve a GitHub one. See the comment onpublish:inelectron-builder.yml; the fix is that key plus dropping the token. It reproduces locally withGH_TOKEN=x electron-builder --mac --publish neverand cannot otherwise: no token on a laptop, no inference, no crash. - And on the one after that, Linux only:
Please specify project homepageout ofFpmTarget. Same underlying gap as 6 —apps/desktop/package.jsonis a stub, and electron-builder reads its metadata from there, never from the root manifest.debis the only target that insists on a homepage, so macOS and Windows had already gone green;AppImagebuilds without one too, which is why the log shows it succeeding one line above the failure. Fixed by giving the manifest ahomepage.linux.maintainerwas already set and quietly suppressed a second error in the same list, for an author with an email.
Number 6 is the one to internalise if you only keep one. Every other entry fails the same way on a laptop as in CI; that one is a bug you can only provoke by setting a CI environment variable, in code that runs after the build has visibly succeeded, reported under a heading about cleanup.
That 404 is also silent about ordering: the tarball is packed and the provenance
statement is signed and published to the sigstore transparency log before the
registry is ever contacted. A log full of successful-looking provenance output can
still end in E404.
Trusted publishing (OIDC)
There is no NPM_TOKEN. npm is
restricting tokens that bypass 2FA,
so publish-npm authenticates with GitHub's OIDC identity instead: id-token:
write is the only credential, and provenance is signed automatically — which is
why --provenance is absent and adding it back is not a no-op. Requirements
are met: npm ≥ 11.5.1 and Node ≥ 22.14 (node-version: 24 resolves to 24.18,
which bundles npm 11.16).
This works only because green-tunnel already exists on npm. A trusted
publisher is configured per package in the npmjs.com UI, which needs the
package to be there first (npm/cli#8544) —
OIDC cannot perform a package's first publish. Publishing a brand-new package
would have meant a token publish to bootstrap it, and avoiding that is one more
reason the single-package layout is the right one. A configuration created after
20 May 2026 must also explicitly select at least one allowed action; older
ones defaulted to npm publish.
snapcraft.yaml was not carried over: it was pinned to v1.7.4 on core18
(EOL) and built the old CLI. electron-builder can emit a snap if the Linux
target list ever wants one.
Roadmap
Roughly in order:
- Exercise and fix the
SystemProxydrivers on Linux and Windows — macOS is now done (see Verified). Re-read the stranded-proxy section above first; those bugs live in the Linux/Windows drivers' shape too. - Packaging: signing, notarization, auto-update, and a Windows/Linux run.
macOS now builds (see Verified) but ships unsigned.
The icons are all generated from
assets/logo.png— the same 2000×2000 artwork v2 shipped asgui/icon.png, byte for byte — by the recipe recorded inelectron-builder.yml. Regenerate all four together if it ever changes:build/icon.{icns,ico,png}for the installer andresources/icon.pngfor the windows Linux and Windows draw themselves, plus the dev dock. - CI: both workflows have run, and the tag pushes so far have failed
seven ways. All seven are fixed.
Auto-update is deliberately not configured —
publish: null— so adding it means givingelectron-builder.ymla real provider block and uploading thelatest-*.ymlfiles, which the artifact globs do not currently match. The release still needs a trusted publisher configured forgreen-tunnelon npmjs.com (SadeghHayeri/GreenTunnel, workflowpublish.yml) — the workflow carries no token, so without itnpm publishcannot authenticate. Then push a tag. - More tests:
http/head.ts, the DNS resolvers against a stub server, an integration test that drivesProxyover a loopback TLS server. - Features worth considering — per-site rules, a bypass list in the UI, SNI-aware split points (split inside the hostname rather than at a fixed offset), QUIC/HTTP3 handling, PAC-file mode instead of a blunt system proxy. The live connection log that was on this list is now the log panel.
Trending
Quiet since September.about the whole repository
Who's talking about SadeghHayeri/GreenTunnel
- A Hacker News story“GreenTunnel: anti-censorship utility designed to bypass deep packet inspection”Hacker News · Mar 22, 2020 · 280 points
- GFW-knocker/gfw_resist_tls_proxy“راه حل برای Youtube بدون فیلتر”GitHub · Apr 29, 2023 · 4 reactions
- A Hacker News story“GreenTunnel bypasses DPI (Deep Packet Inspection) systems found in many ISPs”Hacker News · Jul 23, 2022 · 2 points
- AppImage/appimage.github.io“Add GreenTunnel”GitHub · Sep 28, 2026 · 1 reactions
- AdguardTeam/AdguardForAndroid“Bypass DPI”GitHub · Jan 14, 2021 · 1 reactions
The biggest 5 of 16 mentions linking to the repository that our crawl found. Each line is their own headline.
See all 16
Talked about on GitHub, Hacker News and Stack Overflow (16 times).
On GitHub · 13
- Apr 29, 2023راه حل برای Youtube بدون فیلتر — GFW-knocker/gfw_resist_tls_proxy4 reactions · 11 comments
- Sep 28, 2026Add GreenTunnel — AppImage/appimage.github.io1 reaction · 1 comment
- Jan 14, 2021Bypass DPI — AdguardTeam/AdguardForAndroid1 reaction · 3 comments
- Apr 29, 2019海少玩具: GreenTunnel v1.3.2 — Kisesy/gscan_quic1 reaction · 10 comments
- Mar 22, 2026New package: SadeghHayeri.GreenTunnel version 2.0.2 — microsoft/winget-pkgs0 reactions · 3 comments
8 more on GitHub
- Jan 2, 2025Package request: GreenTunnel — NixOS/nixpkgs0 reactions · 1 comment
- Feb 8, 2024Rekomendasikan Spoof-DPI daripada PowerTunnel — bebasid/bebasid0 reactions · 2 comments
- Feb 4, 2023Package request: GreenTunnel — NixOS/nixpkgs0 reactions · 1 comment
- Jul 30, 2022[Request] GreenTunnel — ScoopInstaller/Extras0 reactions
- Jul 30, 2022RFP - green-tunnel — chocolatey-community/chocolatey-package-requests0 reactions · 5 comments
- Aug 5, 2020proxy does not work properly — mikf/gallery-dl0 reactions · 5 comments
- Nov 20, 2019What to expect on 7.7 — WMAL/kodachios0 reactions
- Apr 11, 2019Icons for Green Tunnel — numixproject/numix-core0 reactions
On Hacker News · 2
- Mar 22, 2020GreenTunnel: anti-censorship utility designed to bypass deep packet inspection280 points · 77 comments
- Jul 23, 2022GreenTunnel bypasses DPI (Deep Packet Inspection) systems found in many ISPs2 points
On Stack Overflow · 1
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

