Skip to content

About

Basic tollgate implementation example

Topics

Resources

Contributing

Security policy

Stars

12 stars

Watchers

1 watching

Forks

Repository files navigation

TollGate — OpenWRT Router Payment Gateway

tollgate-logo

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.

Mirror, CI and releases on Nostr (ngit)

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

Build artifacts (Nostr, not GitHub releases)

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 tags

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

Core technologies

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

Feature highlights

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

Modules

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.

Installation

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

On OpenWrt 24.10.x and earlier:

opkg install /tmp/tollgate-wrt_<version>_<arch>.ipk

Choosing the admin password (no installer required)

Installing 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>.apk

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

Verifying a manual install (the signed manifest)

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 SHA256SUMS

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

Installing on a box that cannot verify TLS

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_*.ipk

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

Supported devices

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 tollgate CLI is only needed for provisioning, so on this class of device it can be dropped after the first boot to leave room for the nodogsplash dependency closure;
  • install the dependency closure in one apk add transaction. apk add --force-non-repository <file> performs a world sync and removes packages that were previously installed from files, which silently takes nodogsplash back 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.

Configuration

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_size is 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 an identity that maps to an entry in /etc/tollgate/identities.json; factor values 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.

Network settings (v0.0.9)

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 onto br-private) plus loopback.
  • br-mgmt is refused while that bridge does not exist on the router, because naming it would drop br-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 in config.json and takes effect once the bridge exists.
  • loopback-only is strict: every interface except lo loses 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 updates config.json, so the two writers cannot disagree. Setting private_ssid to 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.

Which UI answers the entry ports (v0.0.10)

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.

SSID conventions

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. The XXXX is 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 (same XXXX device code as the public AP), WPA2/PSK. Both radios share it. The passphrase is a memorable Word-Word-Word-NN string set on first boot and preserved on upgrade. The c08r4d0r prefix 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 Portal as its gatewayname.

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.

Testing

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.

Documentation

Design and protocol docs live under docs/:

License

GPL-3.0 — see LICENSE.

About

Basic tollgate implementation example

Topics

Resources

Contributing

Security policy

Stars

12 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages