Library / SDK
HyNetworks/tcp-brutal avatar
HyNetworks/tcp-brutal

tcp-brutal paces TCP sends at a rate you set, as a Linux kernel module

Hysteria's congestion control algorithm ported to TCP, as a Linux kernel module

1,167 stars110 forksCGPL-3.0

At a glance

What is it?
tcp-brutal is a Linux kernel module that ports Hysteria's Brutal congestion control to TCP, so a sending host can pace packets at a known rate instead of probing for bandwidth. It suits servers on a known-capacity link, and it assumes you already know the path's bandwidth.
Who is it for?
Adopt tcp-brutal if you run a sending server on a link whose bandwidth you know, you want to fill that link without probing, and you can accept that an unconfigured connection runs at 1 Mbps. Do not adopt it as a general TCP replacement, because the README says explicitly that you should not set it as the system default congestion control.
Can I use it commercially?
Yes, with conditions. GPL-3.0 is a copyleft licence: if you distribute software that includes it, you must release that software's source code under the same licence. Running it internally without distributing it does not trigger that obligation.
Is it still maintained?
Yes. The repository last received commits 27 days ago.
What is it written in?
Mainly C, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 20, 2026, and from our analysis. They are not legal advice.

Editorial analysis

Brutal sends at a rate instead of probing for one

TCP Brutal is Hysteria's congestion control algorithm ported to TCP, as a Linux kernel module. The README calls it an official subproject of Hysteria, actively maintained to be in sync with the Brutal implementation in Hysteria.

The difference from cubic or BBR is stated in one sentence. Brutal sends at the rate you set. It does not probe for bandwidth like cubic or BBR. It paces packets at the configured rate, and when packets are lost it sends more so that the delivered rate stays at the target.

That is a fundamentally different contract from a loss-based or delay-based algorithm. Cubic grows its window until it hits loss, BBR estimates the bottleneck from delivery rate and round-trip time, and both are trying to discover the capacity of the path. Brutal takes the capacity as an input, which means the operator is responsible for knowing it.

The README is honest about the failure mode that follows. It assumes you know the bandwidth of the path, and if you set it too high you only produce loss. On a link with a real bottleneck downstream, a rate set above what the path carries produces exactly the congestion the module was supposed to avoid.

The second property follows from the first. It works on one side. Brutal controls sending, and the TCP protocol on the wire is unchanged, so the other end needs nothing. The README adds the practical consequence: proxy users mostly download, so running it on the server alone gives most of the benefit. The receiver does not need to know anything, does not need a matching kernel module, and does not need to be running Hysteria.

This is what makes the port possible at all. A congestion control algorithm that needed both ends to agree would not be a drop-in TCP congestion control at all, it would be a new protocol.

Version 2 moved the configuration from the application to a rule table

The change in version 2 is the single biggest thing to understand about the current interface, and the README announces it at the top.

TCP Brutal no longer needs special support from the application. You set a rate for a destination once, and every connection to it uses Brutal, any program, any TCP-based protocol. The earlier design required the program to opt in on its socket, which meant only applications that knew about the module could use it. Version 2 removes that requirement for the common case, and the module is described as useful right now without further work.

The application-level path still exists for the cases that need it. The README shows enabling it on a socket directly:

python
s.setsockopt(socket.IPPROTO_TCP, TCP_CONGESTION, "brutal".encode())

That is the standard Linux way of selecting a congestion control algorithm, and a program that knows about Brutal can still set it per socket and then set the rate, the congestion window gain and optionally a group id:

python
TCP_BRUTAL_PARAMS = 23301

rate = 2000000 # 2 MB/s
cwnd_gain = 15
group_id = 42
brutal_params_value = struct.pack("<QIQ", rate, cwnd_gain, group_id)
conn.setsockopt(socket.IPPROTO_TCP, TCP_BRUTAL_PARAMS, brutal_params_value)

The struct is three fields: a rate in bytes per second, a cwnd gain in tenths so 10 means 1.0, and a group id where 0 means the rate applies to this connection only, which the comment calls the v1 behaviour. The congestion window gain recommendation is 1.5x to 2x, written as 15 or 20 because the kernel has no floating point.

The 12-byte v1 struct without group_id is still accepted, so a program written against the older interface keeps working. The option can also be read back with getsockopt, and a member of a group reports the group's rate, cwnd gain and group id rather than its own.

The 23301 number is the socket option, and it is worth noting that the README says an application that tries to set its own parameters on a locked connection gets EPERM and should simply carry on. That is the locking behaviour, and it is deliberate.

brutalctl rules group connections by destination prefix, and the rate is shared

The rule interface is what version 2 added, and it is where the grouping model lives.

The command is run on the side that sends the data, which for downloads from your server is the server:

bash
# Everything sent to 203.0.113.5 shares 100 Mbps, whichever program sends it
brutalctl add 203.0.113.5/32 100

The README is precise about what that number means. It is the total for all connections to that destination, which should be what the receiving side's link can actually take. A single active connection gets all of it, and several share it. No application support is needed, so a plain web server, proxy tool, rsync or SSH to that address is covered.

The management commands are a small set:

bash
brutalctl list                   # rules, and how many connections each one has right now
brutalctl add 203.0.113.5/32 50  # change the rate; existing connections follow immediately
brutalctl del 203.0.113.5/32
brutalctl flush

The timing rules are spelled out and they matter. A connection picks up a rule only when it is established, so you add rules before making the connections that should use them. Running programs need no restart, but their existing connections are unaffected. After that, add on the same prefix changes the rate live for every connection in the group, and del stops new connections from matching while existing ones keep the old rate until they close.

That last behaviour is the interesting design decision. Deleting a rule is not retroactive, and it does not tear down the connections that were using it. The reason is that these are established TCP connections with a negotiated rate, and changing the rate of a live connection is a different operation from routing a new one. The consequence for operators is that deleting a rule is a gradual transition, not a switch, and you should not expect a del to take effect on traffic already in flight.

Groups are formed in two ways, by a destination rule or by an application setting a group id on its sockets. And the README explains that bandwidth is not divided statically: whoever is sending gets it, and a connection that uses less than its share leaves the rest to the others.

Finally, rules do not survive a reboot, so the add commands need to go in a boot script.

A rule needs a matching route, and the route is what locks applications out

This is the part of the design that is easiest to get wrong, and the README gives it its own explanation.

Two things are needed for a rule to take effect, and brutalctl does both. The rule itself, kept by the module in /proc/net/tcp_brutal/rules, and a route that makes the kernel pick brutal for new connections to that prefix. The route is the same shape as ip route replace <prefix> via <gateway> congctl lock brutal proto 233, with the next hop copied from the current routing table.

The proto 233 marker is what makes this safe to automate. Routes it creates carry proto 233, so ip route show proto 233 lists them and brutalctl never touches other routes. The noroute option exists for the case where you manage the route yourself, for example when the destination is reached through a policy routing table. When several rules match, the longest prefix wins.

The lock keyword in the route is the security property, and the README describes it as locked by default. With the route's lock and the rule together, applications cannot change the algorithm or the rate on those connections. An application that itself supports TCP Brutal gets EPERM when it tries and should simply carry on. If you want applications to be allowed to set their own params, you add the rule with nolock.

The related warning is equally direct. Do not set brutal as the system default congestion control. A connection with no rule and no application settings runs at 1 Mbps. The reasoning is that applications that support TCP Brutal enable it on their own sockets, and rules cover everything else, so there is no reason to make it the default.

That last sentence is a safety valve rather than a technical necessity. A module whose default rate is 1 Mbps and whose misconfiguration produces loss rather than slowness is a module that fails in the direction of being slow, which is the right direction for a default.

The module is C, built with kbuild and DKMS

The repository is a Linux kernel module written in C, and the layout shows the four source files that make it up. There is brutal.h with the shared definitions, brutal_cc.c which is the congestion control implementation, brutal_sockopt.c for the socket options, and brutal_rules.c for the rule table, plus tools/brutalctl.c for the user space tool. There is also an example/ directory with client.py and server.py, a flake.nix, a Makefile and a .clang-format.

The Makefile is a standard kbuild one with some care in it. KERNEL_RELEASE comes from uname -r and KERNEL_DIR from /lib/modules/$(KERNEL_RELEASE)/build, and the all target runs the kernel build system with M pointing at the current directory. There are load and unload targets that insmod and rmmod the module.

Two details show the maintainers have hit real problems. The first is the LLVM detection. Modules for a clang-built kernel, CONFIG_CC_IS_CLANG, must be built with LLVM=1, so the Makefile greps the kernel configuration for that flag and sets the variable. The second is the BBRv3 patchset problem. Kernels with the BBRv3 patchset, such as XanMod and others, replace the min_tso_segs hook with tso_segs(sk, mss_now), which returns the burst size instead of a floor, and since no version macro covers it, the Makefile greps the kernel headers being built against and defines BRUTAL_HAVE_TSO_SEGS accordingly.

The formatting targets use clang-format-18 by default, with a format target and a format-check target that runs in dry-run mode with --Werror, so the style is enforced rather than suggested.

The install path the README documents is a single command:

bash
bash <(curl -fsSL https://tcp.hy2.sh/)

That installs the kernel module through DKMS and puts the brutalctl tool in /usr/local/bin. Linux 5.10 or later is required. On NixOS with flakes you add the module to your flake.nix, and the snippet in the README shows the input, the nixosModules.default entry and a boot.tcp-brutal.enable option, and it provides brutalctl as well.

GPL-3.0, and a version 2 that reset the interface

The licence is GPL-3.0, with the LICENSE file in the repository as the authoritative text, and this is a licensing statement rather than legal advice. For a Linux kernel module that is a common licence choice.

The release record shows a project that changed shape rather recently. v2.0.1 on 2026-09-26, v2.0.0 on 2026-09-05, and before that v1.0.3 from 2024-07-30. The repository is not archived and the last push was on 2026-09-26.

The version gap between v1.0.3 in July 2024 and v2.0.0 in September 2026 tells you the v1 line sat largely still while the application-based interface was in use, and that the version 2 rewrite is a recent and substantial change rather than an incremental one. The jump from 1.x to 2.0.0 was the moment application support stopped being required, and anyone who automated against the v1 socket option interface will find that the rule table now has to be the entry point.

The README also has a Chinese version at README.zh.md, and the project is tied to the Hysteria ecosystem, with the algorithm itself documented in the Hysteria documentation under bandwidth behaviour explained. That coupling has a practical consequence for anyone reading about it: the design rationale for the algorithm lives in the Hysteria docs, and the reason the project is kept in sync with Hysteria's implementation is stated as the reason it is actively maintained.

For a kernel module, the maintenance question is a different one from most projects. A module has to keep compiling against kernels that change internal APIs, which is exactly what the BBRv3 and clang handling in the Makefile is about, and it has to keep working with the distribution's DKMS packaging, which is why the install goes through DKMS rather than a prebuilt binary.

Editorial conclusion

Adopt tcp-brutal if you run a sending server on a link whose bandwidth you know, you want to fill that link without probing, and you can accept that an unconfigured connection runs at 1 Mbps. Do not adopt it as a general TCP replacement, because the README says explicitly that you should not set it as the system default congestion control. Before you install, confirm Linux 5.10 or later, read the rules and routes section carefully, since a rule needs both a rule entry and a route to take effect, and remember that rules do not survive a reboot so the add commands need to be in a boot script.

Frequently asked questions

How do I install tcp-brutal?

Run bash <(curl -fsSL https://tcp.hy2.sh/), which installs the kernel module through DKMS and places the brutalctl tool in /usr/local/bin. Linux 5.10 or later is required. On NixOS with flakes, add the module to your flake.nix and enable boot.tcp-brutal.enable, which also provides brutalctl.

How do I set a rate for a destination with brutalctl?

Run brutalctl add 203.0.113.5/32 100 on the sending side. The number is the total for all connections to that destination, so a single active connection gets all of it and several share it. Any program is covered, including a plain web server, proxy tool, rsync or SSH.

Do rules affect connections that already exist?

A connection picks up a rule only when it is established, so rules should be added before the connections that should use them are made. Adding a rule again for the same prefix changes the rate live for every connection in the group, and deleting a rule leaves existing connections sharing the old rate until they close while new ones no longer match. Rules also do not survive a reboot.

Why does a brutalctl rule need a route as well?

Two things are needed for a rule to take effect, and brutalctl does both. The rule is kept by the module in /proc/net/tcp_brutal/rules, and a route makes the kernel pick brutal for new connections to that prefix, the same as ip route replace with congctl lock brutal proto 233. The proto 233 marker means brutalctl never touches other routes.

Can an application change the rate set by a brutalctl rule?

No, not by default. The route's lock and the rule together mean applications cannot change the algorithm or the rate on those connections, and an application that tries gets EPERM. You can add the rule with nolock if you want applications to set their own parameters.

Official sources

  1. HyNetworks/tcp-brutal on GitHub
  2. Issues
  3. License: GPL-3.0
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/hynetworks-tcp-brutal.svg)](https://hysenlabs.com/projects/hynetworks-tcp-brutal)

Was this useful?