Charon is a temporary, foreground bandwidth limiter for Linux and macOS. It applies the same limit to upload and download traffic on one network interface, then restores normal networking when it exits.
The project has two native implementations:
| File | Platform | Native traffic-control system |
|---|---|---|
charon.sh |
Linux | tc from iproute2 |
charon_macos.sh |
macOS | PF and dummynet (pfctl and dnctl) |
Charon does not require Homebrew, Python, containers, or a background service.
- Limits both ingress and egress traffic.
- Runs visibly in the foreground.
- Restores normal bandwidth on
Ctrl+C, termination, or ordinary exit. - Prevents two Charon processes from managing the same interface.
- Detects PID reuse instead of trusting a PID alone.
- Recovers journaled state left by most forced or abnormal exits.
- Uses unique, project-owned handles, filters, pipes, and anchors.
- Refuses to overwrite pre-existing traffic-control configuration it cannot restore exactly.
- Supports IPv4 and IPv6 traffic.
- Makes no permanent system configuration changes.
- Bash
ipandtcfrom theiproute2package- Root privileges through
sudo - Kernel support for TBF, ingress qdiscs, u32 filters, and policing; these are available in normal general-purpose distribution kernels
Install iproute2 if it is missing:
# Arch Linux / CachyOS
sudo pacman -S iproute2
# Fedora
sudo dnf install iproute
# Debian / Ubuntu
sudo apt install iproute2- macOS with the built-in
/sbin/pfctl,/usr/sbin/dnctl, and/sbin/ifconfig - Root privileges through
sudo - The standard
com.apple/*dummynet anchor in the active PF ruleset
No additional packages are required.
Make the script for your operating system executable:
chmod +x charon.sh # Linux
chmod +x charon_macos.sh # macOSRun Charon with an interface and rate:
# Linux
sudo ./charon.sh enp5s0 1mbit
# macOS
sudo ./charon_macos.sh en0 1mbitLeave the terminal open while the limit is active. Press Ctrl+C to stop
Charon and restore normal bandwidth.
Running either script with --help lists the interfaces it can see:
./charon.sh --help
./charon_macos.sh --helpYou can also identify the interface carrying the default route.
ip route show defaultExample output:
default via 192.168.1.1 dev enp5s0 proto dhcp
The interface in this example is enp5s0.
route get default | awk '/interface:/{print $2}'Wi-Fi is commonly en0, but the actual name should always be checked.
For scripts, aliases, and cross-platform use, prefer bit-rate units:
| Example | Meaning |
|---|---|
500kbit |
500 kilobits per second |
1mbit |
1 megabit per second |
25mbit |
25 megabits per second |
2.5gbit |
2.5 gigabits per second |
Units are case-insensitive, the value must be positive, and bit, kbit,
mbit, and gbit may optionally be followed by /s on either platform.
The Linux version also accepts the bps, kbps, mbps, gbps, and tbps
spellings understood by tc. Those denote byte-rate units in tc; use
kbit, mbit, or gbit when you mean network speeds in bits per second.
On macOS, gigabit values are translated into the highest unit documented by
Apple's dnctl implementation.
On the appropriate operating system, the script can be installed as charon:
# Run this on Linux
sudo install -m 0755 charon.sh /usr/local/bin/charon
# Or run this on macOS
sudo install -m 0755 charon_macos.sh /usr/local/bin/charonIt can then be invoked from any directory:
sudo charon en0 1mbitUse the actual interface name for the machine.
- Arguments, privileges, dependencies, interface existence, and rate syntax are validated.
- Charon acquires an atomic, per-interface lock.
- Existing traffic-control state is checked for conflicts.
- Project-owned shaping rules are applied.
- Charon remains in the foreground until it is stopped.
- Cleanup removes only state identified as belonging to Charon.
The foreground process is intentional. It provides an obvious owner for the
temporary rules and makes Ctrl+C a reliable off switch.
Egress traffic is shaped with a Token Bucket Filter using a 32 KB burst and a 400 ms latency allowance. Ingress traffic is policed at the same configured rate with a 100 KB burst.
Linux cannot truly shape packets that have already arrived from the network. Ingress policing therefore drops excess incoming packets, allowing protocols such as TCP to reduce their sending rate. Download measurements may fluctuate more than upload measurements, especially at very low limits.
Before applying anything, Charon inspects the interface:
- An egress qdisc hierarchy whose displayed handles are all
0:is considered implicit kernel/driver state and safe to replace temporarily. This includes common defaults such asfq_codel,noqueue, andmq. - Any egress qdisc with a nonzero handle is treated as user-managed, including
a child beneath an implicit
mqroot. - Any existing ingress or
clsactqdisc is treated as user-managed.
If user-managed state is found, Charon exits rather than destroying it. Linux cannot reliably reconstruct an arbitrary qdisc hierarchy from display output, so refusing is safer than pretending it can restore the configuration.
During setup, progress is journaled under /run. If the process is killed
between individual tc operations, the next invocation for the same interface
can identify and remove the incomplete Charon state. If ordinary cleanup cannot
confirm removal, Charon keeps that journal instead of discarding the recovery
information.
macOS uses two separate dummynet pipes: one for outgoing packets and one for incoming packets. Using separate pipes prevents the two directions from sharing one half-duplex bandwidth pool.
PF rules are loaded into a private anchor beneath com.apple/*. Charon does not
replace /etc/pf.conf or flush the main PF ruleset. This protects firewall,
Internet Sharing, AirDrop, VPN, and other system-managed PF state.
When PF is enabled, Charon requests its own enable-reference token. Cleanup
releases that token with pfctl -X; it does not disable PF if another macOS
component still needs it.
Dummynet pipe numbers are allocated from a private high-number range. A global allocator lock prevents simultaneous Charon processes on different interfaces from selecting the same pipes.
If the active PF configuration does not expose Apple's wildcard dummynet anchor, Charon exits. It will not replace a custom PF configuration merely to make itself work.
The normal way to stop Charon is:
Ctrl+C
It can also be terminated from another terminal:
sudo kill <pid>Avoid kill -9. SIGKILL cannot be trapped by any program, so immediate
cleanup is impossible. If Charon is killed that way, run the same script again
on the same interface. Its stale-state recovery will remove journaled remnants
before applying the new limit. A reboot also clears the temporary kernel state.
Do not manually delete Charon's lock file while its process is running. The lock contains the ownership information needed for conservative cleanup.
Open a second terminal while Charon is running.
sudo tc qdisc show dev enp5s0
sudo tc filter show dev enp5s0 parent ffff:The root qdisc should include tbf, and the ingress filter should include a
police action at the requested rate.
sudo dnctl pipe show
sudo pfctl -s Anchors | grep charonTwo Charon-owned pipes should be visible with the requested bandwidth.
Real-world throughput will usually be slightly below the configured rate due to protocol overhead, queue behavior, TCP congestion control, and the speed test's own measurement method.
Charon limits the interface named on the command line.
- Limiting a physical interface such as
en0orenp5s0generally also limits encrypted VPN traffic that ultimately crosses that interface. - Limiting a tunnel interface such as
utun3,tun0, orwg0targets traffic visible inside that tunnel. - Interfaces may disappear when a VPN disconnects. Stop Charon before removing the interface whenever possible.
Make the file executable:
chmod +x charon.sh charon_macos.shUse sudo:
sudo ./charon.sh enp5s0 1mbitRun the relevant script with --help, or use the default-route commands above.
Do not use a friendly name such as Wi-Fi; Charon needs the kernel interface
name, such as en0.
Inspect it without changing it:
sudo tc qdisc show dev enp5s0
sudo tc filter show dev enp5s0Another network manager, QoS service, container system, VPN, or prior manual configuration may own those rules. Charon intentionally refuses to erase them. Stop the owning service or select another interface instead of blindly deleting the qdiscs.
The active PF main ruleset has been customized or was not loaded normally. Charon refuses to overwrite it. Restore or deliberately integrate Apple's dummynet anchor in the PF configuration before trying again.
Return to the terminal running Charon and press Ctrl+C. If the recorded PID no
longer exists, the next invocation will treat the lock as stale and recover it.
This is expected from ingress policing. Incoming excess packets must be dropped rather than queued before arrival. TCP will converge toward the configured rate, but short tests and very small limits can look uneven.
Charon also limits the SSH traffic crossing the selected interface. Be careful when applying very low limits remotely. Running it from a local terminal is safer when experimenting.
Charon is intentionally small and temporary. It currently does not provide:
- Different upload and download rates
- Per-application, per-process, per-host, or per-port limits
- Persistent limits after reboot
- A daemon, launch agent, or systemd service
- A graphical interface
- Latency, jitter, or packet-loss simulation as user-facing options
Those features require a broader policy and configuration model than Charon's current one-command, one-interface contract.
Charon follows four rules:
- Validate before changing kernel state.
- Never silently replace traffic-control configuration that may belong to another program.
- Record ownership before and during multi-step setup.
- Remove only resources that can still be identified as Charon's.
The scripts do not access the network, download dependencies, modify startup configuration, or transmit data. All state is local and temporary.