Eric Guo's blog.cloud-mes.com

Hoping writing JS, Ruby & Rails and Go article, but fallback to DevOps note

Installing Snell Server on Rocky Linux 9 With Network Bandwidth Limiting

Permalink

My previous Snell installation note was written for CentOS 7 in 2020. Several years later, CentOS 7 is obsolete, predictable network interface names are common, and Snell has moved to version 5.

This guide installs Snell Server on Rocky Linux 9, manages it with systemd, opens the required firewall ports, and optionally limits the server’s outbound network throughput using Linux Traffic Control.

Snell is a lightweight encrypted proxy protocol developed for Surge. The official server is distributed as a single binary with no external runtime dependencies other than glibc. Snell v5 also adds a QUIC proxy mode, which requires the server’s UDP port to be reachable.

1. Update Rocky Linux and install the required tools

Log in as root, or prefix the commands with sudo.

dnf update -y
dnf install -y wget unzip firewalld iproute-tc

The iproute-tc package provides the tc command used later for bandwidth limiting.

Enable and start the firewall:

systemctl enable --now firewalld
systemctl status firewalld

Rocky Linux 9 uses firewalld for common firewall management. Its runtime and permanent configurations are separate, so permanent rules must be explicitly added when they should survive a reboot.

2. Create a dedicated Snell account

Snell does not need an interactive login account:

useradd \
--system \
--home-dir /opt/snell \
--create-home \
--shell /sbin/nologin \
snell

Create the application directory:

mkdir -p /opt/snell
chown snell:snell /opt/snell
chmod 750 /opt/snell

3. Download Snell Server

At the time of writing, the latest official release is Snell Server 5.0.1.

For a typical x86-64 VPS:

cd /tmp
wget https://dl.nssurge.com/snell/snell-server-v5.0.1-linux-amd64.zip
unzip snell-server-v5.0.1-linux-amd64.zip
install \
-o snell \
-g snell \
-m 750 \
snell-server \
/opt/snell/snell-server
rm -f snell-server snell-server-v5.0.1-linux-amd64.zip

For an ARM64 server, use the official AArch64 package instead:

https://dl.nssurge.com/snell/snell-server-v5.0.1-linux-aarch64.zip

The official Snell page currently provides builds for AMD64, i386, AArch64, and ARMv7.

Confirm that the binary runs:

/opt/snell/snell-server --help

4. Generate the Snell configuration

Run the built-in configuration wizard as the snell user:

cd /opt/snell
sudo -u snell ./snell-server

The wizard asks for values such as:

  • Listening address
  • Listening port
  • Pre-shared key, or PSK
  • IPv6 support

For example, select port 11666.

After the wizard finishes, verify the generated file:

cat /opt/snell/snell-server.conf

A typical configuration resembles:

[snell-server]
listen = 0.0.0.0:11666
psk = YOUR_RANDOM_SECRET
ipv6 = false

Protect the configuration because it contains the PSK:

chown snell:snell /opt/snell/snell-server.conf
chmod 600 /opt/snell/snell-server.conf

Do not publish the real PSK in screenshots, shell history, or a blog post.

5. Create the systemd service

Create /etc/systemd/system/snell.service:

cat >/etc/systemd/system/snell.service <<'EOF'
[Unit]
Description=Snell Proxy Service
Documentation=https://kb.nssurge.com/surge-knowledge-base/release-notes/snell
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=snell
Group=snell
WorkingDirectory=/opt/snell
ExecStart=/opt/snell/snell-server -c /opt/snell/snell-server.conf
Restart=on-failure
RestartSec=5s
LimitNOFILE=32768
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
ReadWritePaths=/opt/snell
[Install]
WantedBy=multi-user.target
EOF

Reload systemd and start Snell:

systemctl daemon-reload
systemctl enable --now snell

Check its status:

systemctl status snell --no-pager

View recent logs:

journalctl -u snell -n 100 --no-pager

Follow the logs in real time:

journalctl -u snell -f

Confirm that the selected port is listening:

ss -lntup | grep 11666

6. Open the Snell port in firewalld

Snell accepts its primary connection over TCP. Snell v5’s QUIC proxy mode additionally uses UDP, so open the same port for both protocols.

firewall-cmd --permanent --zone=public --add-port=11666/tcp
firewall-cmd --permanent --zone=public --add-port=11666/udp
firewall-cmd --reload

Verify the rules:

firewall-cmd --zone=public --list-ports

Expected output should include:

11666/tcp 11666/udp

If the server uses a cloud-provider firewall or security group, the same TCP and UDP ports must also be opened there.

7. Configure Surge

Add a proxy entry to the Surge profile:

[Proxy]
My-Snell-Server = snell, YOUR_SERVER_IP, 11666, psk=YOUR_RANDOM_SECRET, version=5

Replace:

  • YOUR_SERVER_IP with the VPS public IP address.
  • YOUR_RANDOM_SECRET with the PSK from snell-server.conf.

The Snell v5 server remains backward compatible with v4 clients. However, QUIC proxy mode is a v5 feature.

8. Limit outbound network throughput

Some VPS providers charge for excess bandwidth or impose fair-use limits. Linux Traffic Control can apply a maximum outbound rate to a network interface.

Linux tc manages queueing disciplines that schedule packets as they leave an interface. The Token Bucket Filter, or TBF, is suitable for applying a simple maximum transmission rate.

Find the public network interface

Do not assume that the interface is named eth0. Rocky Linux systems frequently use names such as:

  • ens3
  • ens18
  • enp1s0
  • enp0s3

Find the interface used by the default route:

ip route show default

For example:

default via 192.0.2.1 dev eth0 proto static metric 100

In this example, the interface is eth0.

You can extract it directly with:

ip route show default | awk '{print $5; exit}'

Rocky Linux recommends modern tools such as ip and nmcli for network inspection and configuration.

Test the rule manually

The following command limits outbound traffic on eth0 to approximately 36 Mbit/s:

tc qdisc replace dev eth0 root tbf \
rate 36mbit \
burst 128kbit \
latency 200ms

Check the active queueing discipline:

tc -s qdisc show dev eth0

Remove the rule:

tc qdisc del dev eth0 root

The limit applies to traffic transmitted through eth0. It therefore affects Snell, SSH, package downloads, web servers, and any other outbound service using that interface.

It does not directly limit inbound traffic. TCP downloads may nevertheless slow down indirectly because acknowledgements and response traffic leave through the rate-limited interface.

Persist the limit with systemd

Create /etc/systemd/system/netlimit.service:

cat >/etc/systemd/system/netlimit.service <<'EOF'
[Unit]
Description=Limit outbound network throughput
After=network-online.target
Wants=network-online.target
Before=snell.service
[Service]
Type=oneshot
ExecStart=/usr/sbin/tc qdisc replace dev eth0 root tbf rate 36mbit burst 128kbit latency 200ms
ExecStop=-/usr/sbin/tc qdisc del dev eth0 root
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
EOF

Replace eth0 with the actual public network interface found earlier.

The use of replace, rather than add, makes the service more tolerant of an existing root queueing discipline. The leading - in ExecStop tells systemd not to treat a missing queueing discipline as a fatal stop error.

Enable and start the service:

systemctl daemon-reload
systemctl enable --now netlimit

Check its status:

systemctl status netlimit --no-pager

Verify the active rate:

tc -s qdisc show dev eth0

Example output:

qdisc tbf 8001: root refcnt 2 rate 36Mbit burst 128Kb lat 200ms

Restarting the service reapplies the configured rate:

systemctl restart netlimit

To disable bandwidth limiting:

systemctl disable --now netlimit

Then confirm that the TBF rule is gone:

tc qdisc show dev eth0

9. Testing the bandwidth limit

The most reliable test is to transfer a sufficiently large file from the Snell server or run a speed test from a remote client.

Remember the unit conversion:

36 Mbit/s ÷ 8 = approximately 4.5 MB/s

Protocol overhead means that the observed application-level transfer speed will normally be slightly lower than 4.5 MB/s.

You can watch the TBF counters while testing:

watch -n 1 'tc -s qdisc show dev eth0'

Pay attention to:

  • Sent bytes
  • Sent packets
  • Dropped packets
  • Overlimits
  • Backlog

An increasing overlimits counter is normal: it shows that TBF is delaying packets to enforce the configured rate. A large number of dropped packets or a continuously growing backlog may indicate that the burst or latency values need adjustment.

10. Maintenance commands

Restart Snell:

systemctl restart snell

Check Snell:

systemctl status snell --no-pager

View logs:

journalctl -u snell --since today

Review the configuration:

sudo -u snell cat /opt/snell/snell-server.conf

Check the firewall:

firewall-cmd --zone=public --list-ports

Check bandwidth limiting:

tc -s qdisc show dev eth0

Check both services after a reboot:

systemctl is-active snell netlimit
systemctl is-enabled snell netlimit

11. Upgrading Snell Server

Download and extract the newer binary into a temporary directory, then stop the service and replace the existing executable:

systemctl stop snell
install \
-o snell \
-g snell \
-m 750 \
/path/to/new/snell-server \
/opt/snell/snell-server
systemctl start snell
systemctl status snell --no-pager

The configuration file can normally remain in place, but release notes should be reviewed before each upgrade.

Conclusion

Compared with the old CentOS 7 setup, the Rocky Linux 9 version is mostly familiar:

  • Snell still runs as a small standalone binary.
  • systemd manages startup and recovery.
  • firewalld exposes the selected TCP and UDP ports.
  • tc and TBF provide a simple outbound bandwidth ceiling.

The main detail to watch is the network interface name. Copying eth0 blindly may cause netlimit.service to fail on servers whose public interface is named ens3, ens18, or enp1s0.

Finally, remember that the simple TBF rule limits the entire network interface. It is suitable for a dedicated Snell VPS. On a shared server, more advanced tc classes and filters would be required to limit only Snell traffic.

Fixing Bun Run Dev Failure in Opencode Desktop

Permalink

Today I encountered a classic "works on my machine... wait, no it doesn't" moment while setting up the Opencode Desktop project. Running bun run dev failed with a cryptic Error: Electron uninstall message. Here's the full story of how I diagnosed and fixed it.

My 2026 Monthly Subscription Review

Permalink

Like the previous years 2022 / 2023 / 2024 and 2025, here is my 2026 subscription list. The first number is RMB per month.

My pay:

  1. (68) iCloud 2T
  2. (17) Apple Music family plan
  3. (24) Dragonruby Pro (annual 42 USD)
  4. (33) Bandwagon host (monthly 33 USD, shared)
  5. (6) Adblock Pro (annual 70 RMB)
  6. (6.5) This blog domain (annual 11 USD)
  7. (1.5) 香哈菜谱 (annual 18 RMB)
  8. (49) AWS hosting (3 years 206 USD, monthly 1.35 USD)
  9. (16.8) Meituan biking (monthly)
  10. (110) Cursor.sh AI editor. (yearly 192 USD)
  11. (8.2) Ivory for Mastodon (yearly 98 RMB)
  12. (8) IndieWeb.Social Backer. (monthly 1.5 SGD)
  13. (31) Sublime Text and Merge (3 years, 152 AUD)
  14. (26) Surge (yearly, 46 USD)
  15. (156) ChatGPT (monthly, 22 USD)
  16. (17.9) Kapeli Dash.app (yearly, 215.33 RMB)
  17. (199) Kimi Allegretto for code (monthly, 199 RMB)

TH pay:

  1. (TH) OpenCode Go and Zen (depend on bill)
  2. (TH) Gemini & Nano Banana (depend on bill)
  3. (52.25) Google Workspace Business Starter (yearly 92.65 USD)

So totally 777.9 RMB per month to pay. In the previous 2025 year it was 773 RMB, so almost same compared to 2025.

Build X86 Electron on Arm64 Mac

Permalink

When building a Windows Electron app on Apple Silicon macOS, electron-builder --win defaults to the host architecture — so running:

cd packages/desktop && bun run package:win

produces arm64 Windows binaries instead of the expected x64.

The Fix

Force x64 explicitly with the --x64 flag when invoking the package command:

cd /path/to/packages/desktop
bun run build
bun run package:win -- --x64 --publish never

Or call electron-builder directly:

npx electron-builder --win --x64 --publish never --config electron-builder.config.ts

This produces the standard NSIS x64 artifacts:

dist/SigmaAgents-win-x64-<version>.exe
dist/SigmaAgents-win-x64-<version>.exe.blockmap
dist/latest.yml

To pin a specific version (e.g. 1.14.42), set the OPENCODE_VERSION env var before the build:

OPENCODE_VERSION=1.14.42 bun ./scripts/prepare.ts
bun run build
bun run package:win -- --x64 --publish never

Follow-up: Native Module Mismatch

After building with --x64, the .exe may launch but crash with:

Error: Cannot find module './windowsTerminal'

Root cause: bun run build resolves native modules (e.g. @lydell/node-pty) based on the host platform and architecture — darwin-arm64 — rather than the target win32-x64. The bundle ends up importing the macOS native binding, which fails when the Windows .exe tries to load it at runtime.

The Fix

Set RUST_TARGET=x86_64-pc-windows-msvc so the build step selects the correct native bindings for the target platform:

cd /path/to/packages/desktop
RUST_TARGET=x86_64-pc-windows-msvc bun run build
RUST_TARGET=x86_64-pc-windows-msvc bun run package:win -- --x64 --publish never

Verify

After the build, confirm the main bundle imports the correct platform binding:

rg "node-pty" out/main/index.js
# should show: @lydell/node-pty-win32-x64

And check the packaged app.asar includes the right native module:

npx asar list dist/win-unpacked/resources/app.asar | rg "windowsTerminal"
# should exist under: node_modules/@lydell/node-pty-win32-x64/lib/

TL;DR for cross-building on macOS

Both env vars are needed together for a working Windows build:

RUST_TARGET=x86_64-pc-windows-msvc \
bun run build && \
bun run package:win -- --x64 --publish never

Without RUST_TARGET, you get a working .exe installer but a broken main process that can't find its native modules.

Caveat: Code Signing

The .exe built on macOS will not be Windows-code-signed. Electron-builder's signing routines typically gate on process.platform === "win32" (and often GITHUB_ACTIONS === "true"), so they intentionally skip signing on macOS. If you need a signed Windows release artifact, you must either build and sign on a Windows machine or add a macOS-compatible signing path to your config.

How to Fix Bun v1.3.12 Based Opencode Got Killed: 9 After Bun Turbo Build

Permalink

New bun 1.3.12 release but I got:

$ ./opencode --version
Killed: 9

In fact, the binary was fine. The code signature was not.

Diagnosis

A quick check with codesign revealed the real culprit:

$ codesign -vv ./opencode
./opencode: invalid or unsupported format for signature
In architecture: arm64

On Apple Silicon, macOS enforces code signing strictly. A malformed or corrupt LC_CODE_SIGNATURE load command will cause the kernel to terminate the process immediately with SIGKILL (Killed: 9)—before a single line of your code runs.

You can confirm the signature load command exists with otool:

$ otool -l ./opencode | grep LC_CODE_SIGNATURE
cmd LC_CODE_SIGNATURE

The Fix

Remove the broken signature and replace it with a local ad-hoc signature:

codesign --remove-signature ./opencode
codesign --sign - --force ./opencode

Then verify:

$ codesign -vv ./opencode
./opencode: valid on disk
./opencode: satisfies its Designated Requirement
$ ./opencode --version
0.0.0-eric_dev-202604120639

Using Git blame.ignoreRevsFile Options to Ignore Your Auto Format Coding Commits

Permalink

After running any auto-format on a repository, git blame becomes almost useless because every line is “last touched” by the formatting commit.

Git has a built-in solution: tell git blame to ignore specific commits.

Create .git-blame-ignore-revs in root repo.

.git-blame-ignore-revs
# Formatting / reindent / whitespace-only commits
1a2b3c4d
abcdef01

Enable it in your repo via git

git config --local blame.ignoreRevsFile .git-blame-ignore-revs

Now when you run git blame, those commits will be skipped and you can see the real history again.

Notes

  • Commit .git-blame-ignore-revs into your repo so GitHub (and other developers) can use the same ignore list.
  • GitHub also reads .git-blame-ignore-revs automatically in the web UI, so you’ll get the same “clean blame” there too.

Deploy Next.js 15.5 via Capistrano Into Self-hosting Rocky Linux 9

Permalink

It's very similar post like deploy to Rocky Linux 8, but this time we using a opencode project, this Next.JS enable you add a login via sso to an existing dify app, which is must need for a enterprise env.

Create a new user

adduser sql_chat
gpasswd -a sql_chat wheel
cd /etc/sudoers.d
# you can later change more limit like "sql_chat ALL=(ALL) NOPASSWD:/usr/bin/systemctl"
echo "sql_chat ALL=(ALL) NOPASSWD:ALL" > 97-sql_chat-user
sudo su - sql_chat
mkdir .ssh
chmod 700 .ssh
vi .ssh/authorized_keys # and paste your public key
chmod 600 .ssh/authorized_keys

and make sure you can login via ssh sql_chat@your_server

Install Node.js 22

curl -sL https://rpm.nodesource.com/setup_22.x -o nodesource_setup.sh
sudo bash nodesource_setup.sh
sudo dnf install -y nodejs
sudo yum install gcc-c++ make
sudo npm install -g npm

Fix permissions for the deploy folder

sudo mkdir /var/www
cd /var/www
sudo mkdir sql_chat
sudo chown sql_chat:sql_chat sql_chat/