- Website: tollgate.me
- Release manager (firmware + package builds): releases.tollgate.me
TollGate turns an OpenWRT router into a Cashu-powered payment gateway for internet access. Customers pay in sats (time- or data-based); the router gates network access and sweeps balances to configured Lightning addresses. The same binary also acts as a client to upstream TollGates, so a router can buy internet from another TollGate and resell it automatically.
This repository is mirrored to ngit — git hosting and CI on Nostr. The mirror
carries the default branch, every pr/<slug> branch and every tag, so the whole
repository is reachable without GitHub.
Clone it over Nostr (the nostr:// remote speaks the ngit protocol):
git clone nostr://npub1x677kgulh6lqaa8e658f9ts4s0x692wgrhcptw535877wfkuwnsqztaquc/relay.ngit.dev/tollgate-module-basic-go
git clone https://relay.ngit.dev/npub1x677kgulh6lqaa8e658f9ts4s0x692wgrhcptw535877wfkuwnsqztaquc/tollgate-module-basic-go.git
git clone https://gitnostr.com/npub1x677kgulh6lqaa8e658f9ts4s0x692wgrhcptw535877wfkuwnsqztaquc/tollgate-module-basic-go.git- Browse / open PRs: https://gitworkshop.dev/npub1xh6njjxpze2vwvn0d863dmrxpll0ds04k38t0wqrg5fcr6nf3ursk3xqw3/git.orangesync.tech/tollgate-module-basic-go
- CI: every push is built by ngit-CI — the workflows run from the ngit side, so the mirror above is the build of record.
- Announcement (source of truth for the URLs above): kind
30617,d=tollgate-module-basic-go, by36bdeb239fbebe0ef4f9d50e92ae1583cda2a9c81df015ba91a1fde726dc74e0, relays:wss://relay.ngit.dev wss://gitnostr.com
Artifacts are published as NIP-94 kind 1063 events, not attached to GitHub
releases. Each event carries one url tag per Blossom mirror and an x tag with
the file's sha256 — download from any mirror and verify the hash.
No kind-1063 artifact is announced for this repo yet, so here is the exact query:
nak req -k 1063 -t "A=30617:36bdeb239fbebe0ef4f9d50e92ae1583cda2a9c81df015ba91a1fde726dc74e0:tollgate-module-basic-go" wss://relay.ngit.dev # url + x tagsWhen one appears, download from any url and prove the x sha256 before use:
echo "<x-tag-sha256> artifact" | sha256sum -c -Generated by ngit-readme-section.sh from the live kind-30617 announcement
1eed6f1fcefb335ae49f9a6fca1f4feaea7620df2cd6c183bf563abd04b59953 (created 1788179627). Doc not be hand-edited: re-run the generator.
- Cashu — ecash over mints (cashu.space)
- Nostr — identities, discovery, advertisements, payments (NIP-01)
- Bitcoin Lightning — payout settlement
- NoDogSplash (ndsctl) — the captive portal / gate that authorizes MACs
Wire protocol docs live in the canonical spec repo: OpenTollGate/tollgate. The pre-built captive-portal site that ships in packaging/files/tollgate-captive-portal-site/ is generated from OpenTollGate/tollgate-captive-portal-site.
- Accept Cashu tokens for internet access, by time or by data.
- Automatic Lightning payouts split across any number of profit-share identities.
- Upstream autopay — a TollGate can detect an upstream TollGate and purchase access on your behalf while reselling to its own customers (reseller mode).
- Per-mint pricing, trust allow/blocklists, configurable session increments and renewal thresholds.
- No accounts and no user identifiers: a customer is identified by the MAC address their device uses, always resolved from the request's socket and never from a value the client sends, and an address whose client left is deauthorised instead of staying open (what that means for a customer who changes their address).
Source lives under src/. Go tooling runs from there
(cd src && go build ./..., cd src && go test ./...).
| Module | Role |
|---|---|
| merchant | Prices advertisements, validates incoming Cashu payments, and hands off started sessions to the session manager. Also drives Lightning payouts. See docs/merchant.md. |
| upstream_session_manager | Owns the customer session lifecycle on this router — creates usage trackers (time or bytes), instructs the Valve when to open/close, handles renewal near limit. Formerly the chandler package. See docs/upstream_session_manager.md. |
| upstream_detector | Probes WAN interfaces to discover an upstream TollGate, decides whether to buy from it, and coordinates the reseller flow. Formerly crowsnest. The cross-component flow is documented under "Cross-component flow" in docs/upstream_session_manager.md. |
| wireless_gateway_manager | Wi-Fi gateway selection, connection/reconnection, scanning, reseller-mode network orchestration. See docs/wireless_gateway_manager.md. |
| valve | Thin wrapper over ndsctl that opens/closes gates and authorizes/deauthorizes MACs. |
| config_manager | Schema, loading, migrations, validation, backups of /etc/tollgate/config.json. |
| tollwallet | Cashu wallet operations (mint client, balance tracking, melt). |
| lightning | LNURL-p / Lightning address resolution and invoice fetching for payouts. |
| cli | tollgate CLI for service control, wallet, private network, upstream Wi-Fi, config, health, and SSL/TLS certificates. Entry point: src/cmd/tollgate-cli. See docs/operator-guide.md. |
| tollgate_protocol | Wire-type definitions shared across modules. |
Build artifacts produced by the CI matrix in
.github/workflows/build-package.yml
target both apk (OpenWrt 25.x) and ipk (OpenWrt ≤24.10) formats.
On OpenWrt 25.x:
apk add --allow-untrusted /tmp/tollgate-wrt-<version>.apkOn OpenWrt 24.10.x and earlier:
opkg install /tmp/tollgate-wrt_<version>_<arch>.ipkInstalling the package alone is enough to get a working gateway — the
installer is a convenience, not a dependency. The one thing it cannot decide
for you is the credential behind the :8090 admin board, so choose it at
install time rather than trying to catch a generated one as it scrolls past:
TOLLGATE_ADMIN_PASSWORD=<your choice> \
apk add --allow-untrusted /tmp/tollgate-wrt-<version>.apkThe value is applied to root — the login the board exposes is checked against root's shadow hash — and it is never echoed: it is already yours, and the install log must not become a place to read it from. Supplying one also replaces a credential that is already set, which is what makes this usable for fleet and CI provisioning. If a password is supplied but does not take, the install fails closed and drops the admin listeners rather than leaving the board on the old credential.
If you install without it, the package generates one and prints it exactly
once, and writes /etc/tollgate/admin-credential-provisional. That marker
means "generated, not chosen": read it, then set your own with the passwd command
over SSH (or reinstall with the variable above). While the marker exists, each
setup run says so in the log — the one-time line is gone as soon as the log is
truncated, which every full-setup run does. A deliberately locked root
account is never re-enabled by any of this.
Installing the package by hand skips the installer, and with it the installer's verification. The release carries what you need to do that check yourself: a SHA256SUMS listing every asset, and a SHA256SUMS.sig that is an OpenSSH ed25519 signature of that listing. The signing key's public half is committed to FreedomTechFeed/packages, and the file the verifier consumes is the allowed-signers entry at .github/release-keys/allowed_signers — NOT the bare key beside it at .github/release-keys/release-signing.pub. Both carry the same key, but only the allowed-signers file supplies the principal that the verifier's identity argument is matched against, so that is the one to fetch.
Verify the signature, then the bytes — in that order:
# Run this on a machine you trust; ssh-keygen is not on a stock router.
BASE=https://github.com/FreedomTechFeed/packages/releases/download/v0.6.0-rc1-pre26
# Pin the key file to a commit, not the moving master branch:
KEYS=https://raw.githubusercontent.com/FreedomTechFeed/packages/1f16dae9c67d528dea57b76d89257d32745a50cd
# A stock OpenWrt router ships wget (BusyBox) or uclient-fetch, not curl; use
# whichever this machine has. curl -fsSLO "$URL" works too.
wget -qO SHA256SUMS "$BASE/SHA256SUMS"
wget -qO SHA256SUMS.sig "$BASE/SHA256SUMS.sig"
wget -qO allowed_signers "$KEYS/.github/release-keys/allowed_signers"
ssh-keygen -Y verify -f allowed_signers -I release-signing@freedomtechfeed \
-n freedomtechfeed-release-manifest -s SHA256SUMS.sig < SHA256SUMS
sha256sum --check --strict SHA256SUMSTwo honest notes. First, the artifact itself carries no apk-level signature, so apk refuses it as UNTRUSTED unless it is told otherwise; the provenance comes from the signed manifest above, not from the package, which is exactly why the manifest is verified first and the hash checked second. Second, a stock router is a thin userspace: it ships dropbear rather than ssh-keygen, and wget or uclient-fetch rather than curl. Verify the manifest on a machine you trust, then check the downloaded file's sha256 on the router, and only then install it.
The declared dependency fixes a package install on a box that can still reach the feed: apk resolves the closure, pulls ca-bundle with it, and the box ends up with a trust store. It does not — and cannot — bootstrap a box that has no trust anchors at all. There, apk's first HTTPS fetch already fails with an SSL verify error, and no dependency can repair a fetch that must itself be verified. If the box reports that error while updating, bring the package files to it by other means instead of over the network, then install them locally in one apk call with a single explicit trust override:
# 1. On a machine you trust, obtain BOTH files and verify each against its own
# published hash. The tollgate asset is covered by the release's signed
# SHA256SUMS (above). ca-bundle is an OpenWrt feed package, so verify it
# against the sha256sums the feed publishes beside it, e.g.
# wget -qO - FEED/sha256sums | grep 'ca-bundle-.*\.apk$'
# Do not skip this: ca-bundle IS the trust anchor, so installing it
# unverified and then asking apk to trust it is circular.
scp ca-bundle-*.apk tollgate-wrt_*.apk root@ROUTER:/tmp/
# 2. On the router (OpenWrt 25.12 ships apk-tools 3), install both in one call.
# --allow-untrusted is the ONE deliberate override in this document, and it
# is safe only because step 1 verified both files before they were copied.
cd /tmp
set -- ./ca-bundle-*.apk ./tollgate-wrt_*.apk
for f in "$@"; do
[ -e "$f" ] || { echo "missing staged file: $f"; exit 1; }
done
apk add --allow-untrusted "$@"
# On the older opkg lane (OpenWrt 24.10) the equivalent needs no signature
# override for local files:
# opkg install ./ca-bundle_*.ipk ./tollgate-wrt_*.ipkSet the clock before any of this: a router whose clock is far off still fails certificate validity checks even once it has a trust store.
The same applies to any other package the install needs — the module's runtime dependencies (nodogsplash, jq) must either already be installed or be brought as local files the same way, because a box in this state cannot fetch them from the feed either.
For local packaging experiments use
scripts/build-sdk-package.sh. It cross-compiles
the target binaries locally, stages the canonical packaging/ recipe into the
OpenWrt SDK, and can produce either apk or ipk artifacts.
Whether a package exists for a router at all is decided by the CI build
matrix in
.github/workflows/build-package.yml:
a router is covered when its OpenWrt target and DISTRIB_ARCH match one of
the rows in that matrix (check them with ubus call system board, or
cat /etc/openwrt_release). That is the machine-true definition of
"supported" — there is no per-model board list in the package.
Cudy WR3000 v1 (MediaTek MT7981B, 256 MB RAM) matches the matrix on
mediatek/filogic / aarch64_cortex-a53, board name cudy,wr3000-v1, so the
arm64 package built for that row installs on it. It was exercised on real
hardware against mainline OpenWrt 25.12.5 (r33051-f5dae5ece4): the full
dependency closure (37 packages, including nodogsplash 5.0.2-r2 and its
kmods) installs and nodogsplash runs with the module's keepalive contract
live (trusted MAC plus allow tcp port 22).
Caveat — reproduce the install against current feeds with care
(#552).
nodogsplash's iptables-* dependencies live in the base target feed
(releases/25.12.x/targets/<arch>/packages/), not the arch packages feed —
a repositories list that omits the target feed (typical of some
ImageBuilder-built images) cannot resolve the closure, and apk-tools 2.x
cannot read the 25.12 index format at all. The bench install above ran with
a complete feed set; if apk add reports the iptables-* names missing,
check /etc/apk/repositories lists the target feed before concluding the
packages are gone.
Caveat — 16 MB of flash, and the compressed variant that nonetheless fits.
The WR3000 v1 has 16 MB of SPI-NOR, which is ~15.1 MB of firmware area and
leaves roughly 4.6 MB of free overlay. The default build does not fit: its
payload is ~20 MB uncompressed (usr/bin/tollgate-wrt 12,361,280 B plus
usr/bin/tollgate 7,373,632 B) / ~8.5 MB compressed, apk add fails with
failed to extract usr/bin/tollgate-wrt: No space left on device, and a custom
ImageBuilder image does not fit either. The upx-ultra-brute variant that
this repo's CI already builds for aarch64_cortex-a53 does fit: it shrinks
the payload to 5.34 MiB (usr/bin/tollgate-wrt 3,389.5 KiB plus
usr/bin/tollgate 1,823.8 KiB, plus ~256 KiB of config and captive-portal
files). A real WR3000 v1 was taken through it on 2026-09-27 — installed from the
compressed .apk, rebooted, and came back with tollgate-wrt running and no
volatile helper, so a persistent install is possible on a 16 MB device with
this variant. Two notes for such devices:
- the 1.78 MiB
tollgateCLI is only needed for provisioning, so on this class of device it can be dropped after the first boot to leave room for thenodogsplashdependency closure; - install the dependency closure in one
apk addtransaction.apk add --force-non-repository <file>performs a world sync and removes packages that were previously installed from files, which silently takesnodogsplashback out.
A volatile (tmpfs) install remains the fallback for bench work that cannot free
the space. (Measured with the upx-ultra-brute dev-channel build
main.200.4469994, sha256 29bb68adbb26e67c…; publishing that variant in the
feed release is tracked with the packaging feed, not here.)
COMFAST CF-WR632AX (MediaTek MT7981-class SoC, compact Wi-Fi 6 travel
router) matches the matrix on the same mediatek/filogic /
aarch64_cortex-a53 row as the WR3000 v1, so the arm64 package built for
that row installs on it unchanged — no matrix row was added for it. OpenWrt
supports it officially since 25.12.0 (upstream keeps
target/linux/mediatek/dts/mt7981b-comfast-cf-wr632ax.dts and publishes the
image as openwrt-<version>-mediatek-filogic-comfast_cf-wr632ax-*; see the
device page).
No flash-capacity caveat. The CF-WR632AX carries 128 MiB of SPI NAND
(Winbond W25N01GV), unlike the 16 MB WR3000 v1 above, so the default build has
room and the upx-ultra-brute variant is not required for it.
Requires OpenWrt 25.12.5 or newer with the OpenWrt U-Boot layout. A memory-speed stability issue affected that layout in 25.12.0–25.12.4 and was fixed in 25.12.5 (upstream PRs #22929 / #23416); the stock layout is unaffected. Use 25.12.5 or newer.
Not yet exercised on real hardware. Unlike the WR3000 v1 above, no
CF-WR632AX has been in hand: this paragraph rests on upstream OpenWrt support
and the shared target/architecture row, not on a measured result on this
device. A tester with the unit is being lined up; the text here will be
replaced with results when there are some. There is no persistent-install
caveat for this device — the only known gap is the compressed-variant
publication one, which applies to aarch64_cortex-a53 generally (only default
builds are released) and is tracked with the packaging feed, not here.
TollGate writes a default /etc/tollgate/config.json on first boot.
The current schema version is v0.0.10. An abridged example:
{
"config_version": "v0.0.10",
"log_level": "info",
"metric": "bytes",
"step_size": 22020096,
"margin": 0.1,
"show_setup": true,
"reseller_mode": false,
"private_ssid": "",
"private_key": "",
"private_encryption": "psk2+ccmp",
"admin_access": "both",
"entry_ui": "board",
"accepted_mints": [
{
"url": "https://mint.coinos.io",
"min_balance": 64,
"balance_tolerance_percent": 10,
"payout_interval_seconds": 60,
"min_payout_amount": 128,
"price_per_step": 1,
"price_unit": "sats",
"purchase_min_steps": 0
}
],
"profit_share": [
{ "factor": 0.79, "identity": "owner" },
{ "factor": 0.07, "identity": "c08r4d0r" },
{ "factor": 0.07, "identity": "amperstrand" },
{ "factor": 0.07, "identity": "origami74" }
],
"upstream_detector": {
"probe_timeout": "10s",
"probe_retry_count": 3,
"probe_retry_delay": "2s",
"require_valid_signature": true,
"ignore_interfaces": ["lo", "docker0", "br-lan", "hostap0"],
"only_interfaces": [],
"discovery_timeout": "300s"
},
"upstream_session_manager": {
"max_price_per_millisecond": 0.002777777778,
"max_price_per_byte": 0.00003725782414,
"trust": {
"default_policy": "trust_all",
"allowlist": [],
"blocklist": []
},
"sessions": {
"preferred_session_increments_milliseconds": 60000,
"preferred_session_increments_bytes": 2500000000,
"millisecond_renewal_offset": 10000,
"bytes_renewal_offset": 1225000000
},
"usage_tracking": {
"data_monitoring_interval": "500ms"
}
}
}Key fields:
metric—"bytes"sells data,"milliseconds"sells time.step_sizeis the unit.accepted_mints[*]— per-mint URL, pricing, and payout thresholds. Multiple mints are supported; the first mint holding sufficient balance wins on payout.profit_share[*]— each entry references anidentitythat maps to an entry in/etc/tollgate/identities.json;factorvalues should sum to 1.0.reseller_mode— when true, this router actively purchases from an upstream TollGate and resells to its own customers.upstream_detector/upstream_session_manager— control the client side (buying from an upstream).
ignore_interfaces and only_interfaces gate which WAN-side interfaces
are probed. ignore_interfaces typically needs to list any wireless
interfaces the router itself serves on to prevent self-probing.
Four fields configure the router's own networks. They are declared intent:
the service converges them onto the router (UCI /etc/config/wireless for the
credentials, a generated /etc/nftables.d/34-admin-access-scope.nft for the
scope) after every config set/config save, on tollgate config apply, and
at service start. All four are also on the admin board's Settings page.
| Field | Values | Meaning |
|---|---|---|
private_ssid |
any SSID, ≤ 32 bytes | Name of the private (management) network, on both private radios. Empty keeps the SSID the router minted at setup — <nym>-<code>, built from your nym and the router's one stored device code, the same code that names the hostname and the captive SSID. |
private_key |
8-63 characters | WPA passphrase of the private network. Empty keeps the passphrase the router has. Write-only: no read path ever returns it. |
private_encryption |
psk2+ccmp (default), psk2+tkip+ccmp, psk-mixed+ccmp |
Encryption mode of the private network. WPA3-SAE is not offered: the shipped wpad has no SAE support and selecting it would leave the management network unable to start. |
admin_access |
both (default), br-private, br-mgmt, loopback-only |
Which network may reach the administration surfaces — the board and LuCI, on whichever port pair entry_ui assigns (default board: the board on :8080/:443, LuCI on :8090/:8443). Since the physical LAN ports moved onto it, br-private is the private SSID and the cable. The guest network the customers pay on is never an administration path, whatever this says. |
Notes that matter when you change them:
- The default,
admin_access=both, adds no firewall rule at all: a router that upgrades onto this release is reachable exactly where it was — the private bridge (the private SSID and the physical LAN ports, since the wired ports moved ontobr-private) plus loopback. br-mgmtis refused while that bridge does not exist on the router, because naming it would dropbr-private— which carries the private SSID and the cable, i.e. every administration path — and leave no network able to reach the board. The value stays inconfig.jsonand takes effect once the bridge exists.loopback-onlyis strict: every interface exceptloloses the administration ports, including a VPN or uplink interface you administer over — and the physical LAN ports.- Changing the passphrase from the router's shell
(
tollgate network private set-password) also updatesconfig.json, so the two writers cannot disagree. Settingprivate_ssidto a custom name stops the setup script's<nym>-<code>re-derivation for that SSID (a machine-shaped one is re-derived from the stored code, a custom one is left alone), so the applier and the setup writer agree on what you chose.
One field decides which of the two administration UIs owns the router's entry port pair — 8080 over plain HTTP and 443 over TLS. The field is entry_ui, and it takes two values:
- board (the default) — the TollGate board answers https://.lan/, and LuCI moves to 8090 / 8443.
- luci — the opposite, i.e. exactly the mapping every release before 0.6.0 shipped: LuCI at the entry pair, the board on 8090 / 8443.
The port sets do not change, only which UI answers on each pair, so no firewall fragment, pre-auth entry or guest-path rule moves, and setting the field back to luci reverts the whole change without a downgrade. All four admin ports stay dropped for br-lan clients in both mappings.
The flip is released in two halves and applied only when both are present. The two listeners are written by two packages that install through different paths (the module's 99-tollgate-setup and the feed's 92-tollgate-admin-setup). A module-only upgrade therefore records board and keeps serving the legacy mapping, logging one WARNING that names the feed re-vendor; the release that vendors both halves applies it. The alternative — binding 443 on both instances — is not a wrong answer but a listener that fails to start, so the gate is deliberate. See the entry-port decision record in docs/architecture for the mapping table, the invariants and the release boundary.
On first boot the router derives its Wi-Fi names from a single generated
device code (see #605
and docs/architecture/one-device-code.md).
- Public open AP —
TollGate-XXXX, e.g.TollGate-9C3F. TheXXXXis the four-character device code of[A-Z0-9], minted once on first boot. Both the 2.4 GHz and 5 GHz radios advertise the same SSID (band steering), so clients are handed off between radios seamlessly. - Private management AP —
c08r4d0r-XXXX(sameXXXXdevice code as the public AP), WPA2/PSK. Both radios share it. The passphrase is a memorableWord-Word-Word-NNstring set on first boot and preserved on upgrade. Thec08r4d0rprefix is the project's generic default nym — every installation shares it, so it names the product rather than any operator; an operator who personalizes the nym does so as a visible choice. #531 proposed renaming this default and was closed as superseded by that decision. - nodogsplash name — the captive-portal gate shows
TollGate-XXXX Portalas itsgatewayname.
Branding: a whitelabel installer can pin /etc/tollgate/brand to net4sats,
which swaps the TollGate prefix for that brand's name everywhere. The
public AP format is pinned by tests/contract/check-ssid-format.sh.
Unit tests, from the src/ directory:
cd src && go test -tags testenv ./...The testenv build tag provisions a hermetic temp config dir so the main
package's init() does not depend on /etc/tollgate/config.json — letting the
suite run off-router (CI, dev machines). The tag is a no-op for subpackages.
A single package:
cd src && go test ./upstream_session_manager/...End-to-end tests live in tests/ and use pytest against real router hardware:
| File | Purpose |
|---|---|
| tests/test_copy_images.py | Transfer firmware to target routers. |
| tests/test_install_images.py | Flash firmware (destructive). |
| tests/test_install_packages.py | Install the tollgate-wrt package on a running router. |
| tests/test_network_configuration.py | Verify upstream gateway connectivity. |
| tests/test_ecash_payment.py | End-to-end buy-internet flow. |
| tests/test_ecash_functionality.py | Wallet-level Cashu operations. |
| tests/test_data_measurement.py | Byte accounting across a data-metered session. |
| tests/test_teardown.py | Reset routers between runs. |
See tests/README.md for how to wire up the test fleet.
Design and protocol docs live under docs/:
- docs/operator-guide.md — practical CLI reference for router operators, including client identity, MAC addresses, and what changing one does
- docs/rc-tester-guide.md — install/upgrade/rollback/report guide for the alpha release candidate
- docs/tester-intake.md — the single intake channel, the report template, and the triage/severity rules for alpha testers
- docs/merchant.md
- docs/upstream_session_manager.md — module internals + end-to-end cross-component flow
- docs/data-session-management.md
- docs/wireless_gateway_manager.md
GPL-3.0 — see LICENSE.
