Tools: to Block Internet Access for Any Linux App (While Keeping LAN) How (Update 2)

Tools: to Block Internet Access for Any Linux App (While Keeping LAN) How (Update 2)

Why UFW?

How Does This Actually Work?

Which Approach Should You Use?

Quick Glossary

Before You Start: Back Up Everything

The Firewall Rules (The Core of Everything)

Where Exactly to Paste

For Desktop Apps (GID Match)

For Services (UID Match β€” /etc/ufw/before.rules)

Why This Order?

Safe Way to Edit

Option A: Quick Wrapper Script

3. Create the wrapper script

Verify It Works

Option B: setgid on the Binary

Find the Real Binary

Apply setgid

Verify

Rollback

Option C: Service UID Match

Find the UID

Add UID Rules to UFW

The Ultimate Proof: LAN vs Internet

Don't Forget: Allow Incoming on the Service Port

For Custom Services Without a Dedicated User

Option D: dpkg-divert + Wrapper

The Full Setup

Option D Variant: Compiled C Wrapper

Surviving apt upgrade

Rollback

Option E: Raw iptables / nftables

iptables

nftables

The Security Flaw Nobody Talks About

The Problem: EGID vs Supplementary Groups

"What If I Hide the Binary Path?"

So When IS the GID Approach Good Enough?

When You Need Something Stronger

Bypass-Proof Alternatives (Not-Tested By Me)

Alternative 1: Separate User + UID Match

Alternative 2: Firejail (Easiest True Isolation)

Alternative 3: Network Namespaces (Manual, Full Control)

Quick Comparison

Troubleshooting

Testing Checklist

Emergency Rollback

🎬 Watch it in Action: Full GUI Demo

Summary Ever wanted Jellyfin to stay off the internet? Or Chromium to only work on your local network? Maybe you want to test how an app behaves offline β€” without actually pulling the Ethernet cable. This guide shows you how to block outbound internet for any specific app on Linux while keeping localhost and your home LAN fully functional. I'll cover five approaches, from a quick 2-minute wrapper script to a production-hardened Chromium setup that survives apt upgrades. Then I'll show you the fundamental security flaw that most guides never mention β€” and what to use instead when it actually matters. πŸ”₯ Safety First: Take a Snapshot!

You are modifying core network firewall rules. A simple typo can easily break your internet connection or lock you out of your server. It is highly recommended to take a VM/System Snapshot before starting. If a snapshot is not possible, please take a manual backup of your UFW rules first. Reverting a snapshot takes 10 seconds; troubleshooting a broken firewall can take hours. You might wonder why this guide uses UFW instead of raw nftables or iptables. The answer is simple: safety for beginners. If something goes wrong β€” you accidentally lock yourself out of the network, or an app stops working β€” you can just run sudo ufw disable or even sudo apt remove ufw to instantly restore full connectivity. With raw nftables, one wrong rule can leave you debugging kernel tables for an hour. UFW is a thin wrapper over iptables/netfilter β€” same power, much easier to roll back. Every time a process opens a network socket, the Linux kernel stamps it with the process's UID (User ID) and GID (Group ID). The firewall β€” specifically netfilter, which UFW sits on top of β€” can inspect those stamps on outgoing packets and decide: accept or reject. That's the entire trick: For services (Jellyfin, Syncthing), we match by UID because they already run as dedicated users. For desktop apps (Firefox, Chromium), we match by GID using a no-internet group. If you didn't take a VM or system snapshot, you must back up your current firewall state. Take 30 seconds to save your current rules so you can easily revert them later: If anything goes wrong: Every option below ends up using the same firewall rules. The only difference is how you mark the app. Here's what the rules look like β€” you'll paste these into /etc/ufw/before.rules. Open the file and look for the *filter section at the top: Your file should initially look like this: Once you paste your rules into the editor, it should look exactly like this: Replace GID with your actual numeric group ID: Do the same in /etc/ufw/before6.rules (use ufw6-before-output, allow ::1 and fe80::/10): Same structure, but use --uid-owner with the service's numeric UID: And in /etc/ufw/before6.rules: The rules are evaluated top-to-bottom, first match wins: ⚠️ Warning: Always backup your original firewall rules to a safe, persistent location (like your root directory) before editing. Temporary files in /tmp/ are wiped upon every reboot! Don't edit the live file directly. Backup, copy to a temp file, edit, test, then apply: Time: 2 minutes Β· Best for: Testing, quick experiments This is the fastest way. You create a tiny script that launches any app under a no-internet group. Add the GID firewall rules to UFW and reload. Downside: If you launch the app from the desktop menu, it won't use the wrapper. You'd need to edit the .desktop file: Time: 5 minutes Β· Best for: Desktop apps you always want restricted Instead of a wrapper, you set the GID flag directly on the app's binary. Every time it runs β€” from the menu, terminal, wherever β€” it automatically gets the no-internet group. This is important. Many apps have wrapper scripts. You need the actual ELF binary (Executable and Linkable Format β€” the compiled program file that Linux actually runs): If file says "shell script" or "Python script", dig deeper β€” that script calls the real binary somewhere. Now every process spawned from this binary inherits EGID = no-internet, which the firewall matches. ⚠️ Caveat: This doesn't work on Snap or Flatpak apps β€” they run in sandboxes with their own network stack. For Flatpak, use Flatseal (GUI) to toggle off "Network" permissions, or run flatpak override --user --unshare=network com.app.Name. For Snap, use snap connections app-name and snap disconnect app-name:network to revoke the network plug. Or install the app as a native .deb. Time: 3 minutes Β· Best for: Daemons like Jellyfin, Syncthing, qBittorrent Services already run as dedicated system users. You just match their UID in the firewall. This is the strongest of the five options because a service can't change its own UID. Same as the GID rules above, but use --uid-owner 112 instead of --gid-owner. Paste into before.rules and before6.rules, then: One of the best ways to verify your setup is to try reaching an external site and a local IP in the same process. Here is the result of that test: If your UFW default is "deny incoming" (it should be), LAN clients can't reach your service unless you explicitly allow the port: Time: 15 minutes Β· Best for: Chromium, Electron, multi-process apps Note: dpkg-divert is a Debian/Ubuntu tool. If you're on Fedora, Arch, or another distro, you'll need to manually relocate the binary instead β€” the firewall rules themselves are distro-agnostic. Chromium is special. It spawns renderer processes, GPU processes, utility processes β€” all from different code paths. A simple setgid on one binary won't catch them all. The solution: use Debian's dpkg-divert to relocate the real binary, then put a wrapper at the original path. Every invocation β€” menu, terminal, child processes β€” goes through your wrapper. Add the GID firewall rules, reload UFW, and test. Instead of a shell wrapper, you can compile a minimal C binary. It avoids spawning an extra bash process and the binary isn't human-readable (though strings will still reveal the path β€” see Security Limitations below). Save as /tmp/sg-wrapper.c: Package updates can overwrite your changes. Protect them: Best for: Systems that don't use UFW, or if you prefer direct control. Add to /etc/nftables.conf: Now that you know how to set this up, let's talk about when it's actually enough β€” because the GID-based approach (Options A, B, and D) has a fundamental bypass that most guides never mention. The firewall's --gid-owner match checks the process's EGID (Effective Group ID) β€” not its supplementary group list. Here's what that means in practice: When a user runs a binary directly, their primary group becomes the EGID. The no-internet supplementary group membership is irrelevant to the firewall. And there's a catch-22: sg (which the wrapper uses) requires the user to be a member of the no-internet group. But if they're a member, they also have permission to execute the chmod 0750 binary directly β€” bypassing the wrapper entirely. You might think: "I'll compile the wrapper as a C binary so users can't read the script to find the real path." That doesn't work either: For those cases, keep reading. When the GID approach isn't enough, here are three methods that provide real, kernel-enforced isolation. Note: I haven't personally tested these alternatives end-to-end. They're included for completeness based on documentation and community guides. If you try any of these and find issues (or get them working), feel free to reach out. Run the app as a completely separate user. UID matching cannot be bypassed β€” a user can't change their own UID. Tradeoffs: You lose your keyring, D-Bus session, bookmarks, and cookies from your main user. Wayland compositors may block other users entirely. But the network restriction is absolute. Firejail uses kernel network namespaces under the hood. No firewall rules needed β€” the app physically cannot see the external network. ⚠️ My experience: firejail --net=none works perfectly β€” the app has zero network access. However, I was unable to get LAN-only mode working using the theoretical setup for reference, but your mileage may vary. LAN-only (theoretical): Create /etc/firejail/lan-only.net: For maximum control, create a network namespace directly. No extra packages needed. "sg: no such group"β†’ Group doesn't exist yet. Run sudo groupadd -f no-internet. Internet is still working after adding rulesβ†’ Double-check the numeric UID/GID in your rules matches reality. Make sure you pasted the block right after the :ufw-before-output line, not at the bottom. Run sudo ufw reload. UFW reload failsβ†’ Syntax error in your rules. Test before applying: sudo iptables-restore --test < /etc/ufw/before.rules. If it fails, restore your backup. It works, but breaks after rebootβ†’ You might have iptables-persistent installed, which conflicts with UFW. Remove it: sudo apt remove iptables-persistent. Let UFW handle everything. setgid isn't workingβ†’ You probably applied it to a shell script wrapper, not the real ELF binary. Use readlink -f $(which app) and file to find the actual binary. Snap/Flatpak apps are unaffectedβ†’ They run in sandboxes with their own network stack. Flatpak: Use Flatseal (GUI) to toggle off "Network" permissions, or run flatpak override --user --unshare=network com.app.Name. Snap: Use snap connections app-name and snap disconnect app-name:network to revoke the network plug. Or install the app as a native .deb. DNS seems to leakβ†’ systemd-resolved runs on 127.0.0.53. Since we allow 127.0.0.0/8, DNS resolves even for blocked apps β€” but the actual connections still get rejected. After setting up any option, run through this: If something goes wrong, these commands restore everything: This walkthrough puts the system to the test using a real-world browser (Google Chrome). Here’s exactly what you’ll see: 🚫 The BlockChrome tries to reach Googleβ€”and fails instantly while the firewall rules are active. 🌐 LAN RoutingDespite the block, Chrome successfully loads a local dashboard on your LAN, proving internal traffic still works flawlessly. πŸŽ›οΈ The Control

With a simple toggle: Curious to see it all in action? Watch the full high-resolution 75-second demo: πŸ”— https://khadirullah.com/blog/block-internet-linux-apps/#watch-it-in-action-full-gui-demo ✨ A complete visual walkthrough of the interface, behavior, and control flowβ€”from block to restore. The GID-based approach (Options A–E) is a clean, elegant way to restrict app networking β€” and it's good enough for most personal use cases. If you want to stop Jellyfin from downloading metadata, or prevent a game from phoning home, it works perfectly. But if you need real enforcement against users who know their way around Linux, the GID approach has a fundamental EGID bypass. For those cases, use UID matching (unbypassable for services), Firejail (easiest for desktop apps), or network namespaces (maximum control). Tested on Debian 13 (Trixie) with UFW. Should work on any Debian/Ubuntu-based distro with kernel 4.x+. Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to ? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Command

Copy

$ -weight: 600;">sudo ufw -weight: 500;">disable -weight: 600;">sudo -weight: 500;">apt -weight: 500;">remove ufw no-internet id -u username getent group groupname ps -eo egid,egroup,cmd -weight: 600;">sudo ufw -weight: 500;">status sg groupname command dpkg-divert dpkg-divert --list -weight: 600;">sudo iptables-save > ~/iptables.before -weight: 600;">sudo mkdir -p ~/ufw_rules_backup -weight: 600;">sudo cp /etc/ufw/before.rules ~/ufw_rules_backup/before.rules.backup -weight: 600;">sudo cp /etc/ufw/before6.rules ~/ufw_rules_backup/before6.rules.backup -weight: 600;">sudo iptables-save > ~/iptables.before -weight: 600;">sudo mkdir -p ~/ufw_rules_backup -weight: 600;">sudo cp /etc/ufw/before.rules ~/ufw_rules_backup/before.rules.backup -weight: 600;">sudo cp /etc/ufw/before6.rules ~/ufw_rules_backup/before6.rules.backup -weight: 600;">sudo iptables-save > ~/iptables.before -weight: 600;">sudo mkdir -p ~/ufw_rules_backup -weight: 600;">sudo cp /etc/ufw/before.rules ~/ufw_rules_backup/before.rules.backup -weight: 600;">sudo cp /etc/ufw/before6.rules ~/ufw_rules_backup/before6.rules.backup -weight: 600;">sudo cp ~/ufw_rules_backup/before.rules.backup /etc/ufw/before.rules -weight: 600;">sudo cp ~/ufw_rules_backup/before6.rules.backup /etc/ufw/before6.rules -weight: 600;">sudo ufw reload -weight: 600;">sudo cp ~/ufw_rules_backup/before.rules.backup /etc/ufw/before.rules -weight: 600;">sudo cp ~/ufw_rules_backup/before6.rules.backup /etc/ufw/before6.rules -weight: 600;">sudo ufw reload -weight: 600;">sudo cp ~/ufw_rules_backup/before.rules.backup /etc/ufw/before.rules -weight: 600;">sudo cp ~/ufw_rules_backup/before6.rules.backup /etc/ufw/before6.rules -weight: 600;">sudo ufw reload /etc/ufw/before.rules *filter :ufw-before-input - [0:0] :ufw-before-output - [0:0] :ufw-before-forward - [0:0] ← YOUR RULES GO HERE, right after these lines *filter :ufw-before-input - [0:0] :ufw-before-output - [0:0] :ufw-before-forward - [0:0] ← YOUR RULES GO HERE, right after these lines *filter :ufw-before-input - [0:0] :ufw-before-output - [0:0] :ufw-before-forward - [0:0] ← YOUR RULES GO HERE, right after these lines # --- BEGIN no-internet block (IPv4) --- -A ufw-before-output -m owner --gid-owner GID -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT -A ufw-before-output -m owner --gid-owner GID -d 127.0.0.0/8 -j ACCEPT -A ufw-before-output -m owner --gid-owner GID -d 10.0.0.0/8 -j ACCEPT -A ufw-before-output -m owner --gid-owner GID -d 172.16.0.0/12 -j ACCEPT -A ufw-before-output -m owner --gid-owner GID -d 192.168.0.0/16 -j ACCEPT -A ufw-before-output -m owner --gid-owner GID -j LOG --log-prefix "Blocked noinet: " -A ufw-before-output -m owner --gid-owner GID -j REJECT # --- END no-internet block (IPv4) --- # --- BEGIN no-internet block (IPv4) --- -A ufw-before-output -m owner --gid-owner GID -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT -A ufw-before-output -m owner --gid-owner GID -d 127.0.0.0/8 -j ACCEPT -A ufw-before-output -m owner --gid-owner GID -d 10.0.0.0/8 -j ACCEPT -A ufw-before-output -m owner --gid-owner GID -d 172.16.0.0/12 -j ACCEPT -A ufw-before-output -m owner --gid-owner GID -d 192.168.0.0/16 -j ACCEPT -A ufw-before-output -m owner --gid-owner GID -j LOG --log-prefix "Blocked noinet: " -A ufw-before-output -m owner --gid-owner GID -j REJECT # --- END no-internet block (IPv4) --- # --- BEGIN no-internet block (IPv4) --- -A ufw-before-output -m owner --gid-owner GID -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT -A ufw-before-output -m owner --gid-owner GID -d 127.0.0.0/8 -j ACCEPT -A ufw-before-output -m owner --gid-owner GID -d 10.0.0.0/8 -j ACCEPT -A ufw-before-output -m owner --gid-owner GID -d 172.16.0.0/12 -j ACCEPT -A ufw-before-output -m owner --gid-owner GID -d 192.168.0.0/16 -j ACCEPT -A ufw-before-output -m owner --gid-owner GID -j LOG --log-prefix "Blocked noinet: " -A ufw-before-output -m owner --gid-owner GID -j REJECT # --- END no-internet block (IPv4) --- /etc/ufw/before6.rules ufw6-before-output # --- BEGIN no-internet block (IPv6) --- -A ufw6-before-output -m owner --gid-owner GID -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT -A ufw6-before-output -m owner --gid-owner GID -d ::1 -j ACCEPT -A ufw6-before-output -m owner --gid-owner GID -d fe80::/10 -j ACCEPT # Optional: uncomment for mDNS / DLNA / SSDP LAN -weight: 500;">service discovery # -A ufw6-before-output -m owner --gid-owner GID -d ff00::/8 -j ACCEPT -A ufw6-before-output -m owner --gid-owner GID -j LOG --log-prefix "Blocked noinet v6: " -A ufw6-before-output -m owner --gid-owner GID -j REJECT # --- END no-internet block (IPv6) --- # --- BEGIN no-internet block (IPv6) --- -A ufw6-before-output -m owner --gid-owner GID -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT -A ufw6-before-output -m owner --gid-owner GID -d ::1 -j ACCEPT -A ufw6-before-output -m owner --gid-owner GID -d fe80::/10 -j ACCEPT # Optional: uncomment for mDNS / DLNA / SSDP LAN -weight: 500;">service discovery # -A ufw6-before-output -m owner --gid-owner GID -d ff00::/8 -j ACCEPT -A ufw6-before-output -m owner --gid-owner GID -j LOG --log-prefix "Blocked noinet v6: " -A ufw6-before-output -m owner --gid-owner GID -j REJECT # --- END no-internet block (IPv6) --- # --- BEGIN no-internet block (IPv6) --- -A ufw6-before-output -m owner --gid-owner GID -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT -A ufw6-before-output -m owner --gid-owner GID -d ::1 -j ACCEPT -A ufw6-before-output -m owner --gid-owner GID -d fe80::/10 -j ACCEPT # Optional: uncomment for mDNS / DLNA / SSDP LAN -weight: 500;">service discovery # -A ufw6-before-output -m owner --gid-owner GID -d ff00::/8 -j ACCEPT -A ufw6-before-output -m owner --gid-owner GID -j LOG --log-prefix "Blocked noinet v6: " -A ufw6-before-output -m owner --gid-owner GID -j REJECT # --- END no-internet block (IPv6) --- /etc/ufw/before.rules --uid-owner # --- BEGIN -weight: 500;">service UID block (IPv4) --- -A ufw-before-output -m owner --uid-owner UID -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT -A ufw-before-output -m owner --uid-owner UID -d 127.0.0.0/8 -j ACCEPT -A ufw-before-output -m owner --uid-owner UID -d 10.0.0.0/8 -j ACCEPT -A ufw-before-output -m owner --uid-owner UID -d 172.16.0.0/12 -j ACCEPT -A ufw-before-output -m owner --uid-owner UID -d 192.168.0.0/16 -j ACCEPT -A ufw-before-output -m owner --uid-owner UID -j LOG --log-prefix "Blocked uid: " -A ufw-before-output -m owner --uid-owner UID -j REJECT # --- END -weight: 500;">service UID block (IPv4) --- # --- BEGIN -weight: 500;">service UID block (IPv4) --- -A ufw-before-output -m owner --uid-owner UID -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT -A ufw-before-output -m owner --uid-owner UID -d 127.0.0.0/8 -j ACCEPT -A ufw-before-output -m owner --uid-owner UID -d 10.0.0.0/8 -j ACCEPT -A ufw-before-output -m owner --uid-owner UID -d 172.16.0.0/12 -j ACCEPT -A ufw-before-output -m owner --uid-owner UID -d 192.168.0.0/16 -j ACCEPT -A ufw-before-output -m owner --uid-owner UID -j LOG --log-prefix "Blocked uid: " -A ufw-before-output -m owner --uid-owner UID -j REJECT # --- END -weight: 500;">service UID block (IPv4) --- # --- BEGIN -weight: 500;">service UID block (IPv4) --- -A ufw-before-output -m owner --uid-owner UID -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT -A ufw-before-output -m owner --uid-owner UID -d 127.0.0.0/8 -j ACCEPT -A ufw-before-output -m owner --uid-owner UID -d 10.0.0.0/8 -j ACCEPT -A ufw-before-output -m owner --uid-owner UID -d 172.16.0.0/12 -j ACCEPT -A ufw-before-output -m owner --uid-owner UID -d 192.168.0.0/16 -j ACCEPT -A ufw-before-output -m owner --uid-owner UID -j LOG --log-prefix "Blocked uid: " -A ufw-before-output -m owner --uid-owner UID -j REJECT # --- END -weight: 500;">service UID block (IPv4) --- /etc/ufw/before6.rules # --- BEGIN -weight: 500;">service UID block (IPv6) --- -A ufw6-before-output -m owner --uid-owner UID -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT -A ufw6-before-output -m owner --uid-owner UID -d ::1 -j ACCEPT -A ufw6-before-output -m owner --uid-owner UID -d fe80::/10 -j ACCEPT -A ufw6-before-output -m owner --uid-owner UID -j LOG --log-prefix "Blocked uid v6: " -A ufw6-before-output -m owner --uid-owner UID -j REJECT # --- END -weight: 500;">service UID block (IPv6) --- # --- BEGIN -weight: 500;">service UID block (IPv6) --- -A ufw6-before-output -m owner --uid-owner UID -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT -A ufw6-before-output -m owner --uid-owner UID -d ::1 -j ACCEPT -A ufw6-before-output -m owner --uid-owner UID -d fe80::/10 -j ACCEPT -A ufw6-before-output -m owner --uid-owner UID -j LOG --log-prefix "Blocked uid v6: " -A ufw6-before-output -m owner --uid-owner UID -j REJECT # --- END -weight: 500;">service UID block (IPv6) --- # --- BEGIN -weight: 500;">service UID block (IPv6) --- -A ufw6-before-output -m owner --uid-owner UID -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT -A ufw6-before-output -m owner --uid-owner UID -d ::1 -j ACCEPT -A ufw6-before-output -m owner --uid-owner UID -d fe80::/10 -j ACCEPT -A ufw6-before-output -m owner --uid-owner UID -j LOG --log-prefix "Blocked uid v6: " -A ufw6-before-output -m owner --uid-owner UID -j REJECT # --- END -weight: 500;">service UID block (IPv6) --- /var/log/kern.log # 1. Create a permanent backup -weight: 600;">sudo cp /etc/ufw/before.rules /root/before.rules.backup # 2. Copy to a temporary file for editing -weight: 600;">sudo cp /etc/ufw/before.rules /tmp/before.rules.edit -weight: 600;">sudo nano /tmp/before.rules.edit # paste your rules # 3. Syntax check (safe, doesn't apply) -weight: 600;">sudo iptables-restore --test < /tmp/before.rules.edit # 4. Apply the rules -weight: 600;">sudo mv /tmp/before.rules.edit /etc/ufw/before.rules -weight: 600;">sudo chown root:root /etc/ufw/before.rules -weight: 600;">sudo chmod 644 /etc/ufw/before.rules -weight: 600;">sudo ufw reload # 1. Create a permanent backup -weight: 600;">sudo cp /etc/ufw/before.rules /root/before.rules.backup # 2. Copy to a temporary file for editing -weight: 600;">sudo cp /etc/ufw/before.rules /tmp/before.rules.edit -weight: 600;">sudo nano /tmp/before.rules.edit # paste your rules # 3. Syntax check (safe, doesn't apply) -weight: 600;">sudo iptables-restore --test < /tmp/before.rules.edit # 4. Apply the rules -weight: 600;">sudo mv /tmp/before.rules.edit /etc/ufw/before.rules -weight: 600;">sudo chown root:root /etc/ufw/before.rules -weight: 600;">sudo chmod 644 /etc/ufw/before.rules -weight: 600;">sudo ufw reload # 1. Create a permanent backup -weight: 600;">sudo cp /etc/ufw/before.rules /root/before.rules.backup # 2. Copy to a temporary file for editing -weight: 600;">sudo cp /etc/ufw/before.rules /tmp/before.rules.edit -weight: 600;">sudo nano /tmp/before.rules.edit # paste your rules # 3. Syntax check (safe, doesn't apply) -weight: 600;">sudo iptables-restore --test < /tmp/before.rules.edit # 4. Apply the rules -weight: 600;">sudo mv /tmp/before.rules.edit /etc/ufw/before.rules -weight: 600;">sudo chown root:root /etc/ufw/before.rules -weight: 600;">sudo chmod 644 /etc/ufw/before.rules -weight: 600;">sudo ufw reload no-internet # Create the group -weight: 600;">sudo groupadd -f no-internet getent group no-internet # note the GID (e.g., 1001) # Create the group -weight: 600;">sudo groupadd -f no-internet getent group no-internet # note the GID (e.g., 1001) # Create the group -weight: 600;">sudo groupadd -f no-internet getent group no-internet # note the GID (e.g., 1001) # Add your user to the group so 'sg' doesn't prompt for a password -weight: 600;">sudo usermod -aG no-internet $USER # Add your user to the group so 'sg' doesn't prompt for a password -weight: 600;">sudo usermod -aG no-internet $USER # Add your user to the group so 'sg' doesn't prompt for a password -weight: 600;">sudo usermod -aG no-internet $USER -weight: 600;">sudo tee /usr/local/bin/no-internet > /dev/null <<'EOF' #!/bin/bash exec sg no-internet "$@" EOF -weight: 600;">sudo chmod 755 /usr/local/bin/no-internet -weight: 600;">sudo tee /usr/local/bin/no-internet > /dev/null <<'EOF' #!/bin/bash exec sg no-internet "$@" EOF -weight: 600;">sudo chmod 755 /usr/local/bin/no-internet -weight: 600;">sudo tee /usr/local/bin/no-internet > /dev/null <<'EOF' #!/bin/bash exec sg no-internet "$@" EOF -weight: 600;">sudo chmod 755 /usr/local/bin/no-internet no-internet firefox & no-internet steam & no-internet keepassxc & no-internet firefox & no-internet steam & no-internet keepassxc & no-internet firefox & no-internet steam & no-internet keepassxc & # Should be BLOCKED: sg no-internet -c '-weight: 500;">curl -I -m 10 https://example.com' && echo "FAIL" || echo "BLOCKED βœ“" # Should still work: sg no-internet -c '-weight: 500;">curl -I -m 10 http://192.168.1.1' && echo "LAN works βœ“" || echo "FAIL" # Should be BLOCKED: sg no-internet -c '-weight: 500;">curl -I -m 10 https://example.com' && echo "FAIL" || echo "BLOCKED βœ“" # Should still work: sg no-internet -c '-weight: 500;">curl -I -m 10 http://192.168.1.1' && echo "LAN works βœ“" || echo "FAIL" # Should be BLOCKED: sg no-internet -c '-weight: 500;">curl -I -m 10 https://example.com' && echo "FAIL" || echo "BLOCKED βœ“" # Should still work: sg no-internet -c '-weight: 500;">curl -I -m 10 http://192.168.1.1' && echo "LAN works βœ“" || echo "FAIL" cp /usr/share/applications/firefox.desktop ~/.local/share/applications/ nano ~/.local/share/applications/firefox.desktop # Change: Exec=firefox %u # To: Exec=/usr/local/bin/no-internet firefox %u cp /usr/share/applications/firefox.desktop ~/.local/share/applications/ nano ~/.local/share/applications/firefox.desktop # Change: Exec=firefox %u # To: Exec=/usr/local/bin/no-internet firefox %u cp /usr/share/applications/firefox.desktop ~/.local/share/applications/ nano ~/.local/share/applications/firefox.desktop # Change: Exec=firefox %u # To: Exec=/usr/local/bin/no-internet firefox %u no-internet which firefox # might be /usr/bin/firefox readlink -f "$(which firefox)" # resolves symlinks file "$(readlink -f "$(which firefox)")" # should say "ELF 64-bit" which firefox # might be /usr/bin/firefox readlink -f "$(which firefox)" # resolves symlinks file "$(readlink -f "$(which firefox)")" # should say "ELF 64-bit" which firefox # might be /usr/bin/firefox readlink -f "$(which firefox)" # resolves symlinks file "$(readlink -f "$(which firefox)")" # should say "ELF 64-bit" -weight: 600;">sudo chown root:no-internet /path/to/real/elf/binary -weight: 600;">sudo chmod 750 /path/to/real/elf/binary -weight: 600;">sudo chmod g+s /path/to/real/elf/binary # the magic: setgid bit -weight: 600;">sudo chown root:no-internet /path/to/real/elf/binary -weight: 600;">sudo chmod 750 /path/to/real/elf/binary -weight: 600;">sudo chmod g+s /path/to/real/elf/binary # the magic: setgid bit -weight: 600;">sudo chown root:no-internet /path/to/real/elf/binary -weight: 600;">sudo chmod 750 /path/to/real/elf/binary -weight: 600;">sudo chmod g+s /path/to/real/elf/binary # the magic: setgid bit no-internet firefox & sleep 1 ps -eo pid,uid,egid,cmd | grep firefox # EGID column should show your no-internet GID number firefox & sleep 1 ps -eo pid,uid,egid,cmd | grep firefox # EGID column should show your no-internet GID number firefox & sleep 1 ps -eo pid,uid,egid,cmd | grep firefox # EGID column should show your no-internet GID number -weight: 600;">sudo chmod g-s /path/to/real/elf/binary -weight: 600;">sudo chown root:root /path/to/real/elf/binary -weight: 600;">sudo chmod 755 /path/to/real/elf/binary -weight: 600;">sudo chmod g-s /path/to/real/elf/binary -weight: 600;">sudo chown root:root /path/to/real/elf/binary -weight: 600;">sudo chmod 755 /path/to/real/elf/binary -weight: 600;">sudo chmod g-s /path/to/real/elf/binary -weight: 600;">sudo chown root:root /path/to/real/elf/binary -weight: 600;">sudo chmod 755 /path/to/real/elf/binary flatpak override --user --unshare=network com.app.Name snap connections app-name snap disconnect app-name:network id -u jellyfin # e.g., 112 id -u jellyfin # e.g., 112 id -u jellyfin # e.g., 112 --uid-owner 112 --gid-owner before.rules before6.rules -weight: 600;">sudo ufw reload -weight: 600;">sudo ufw reload -weight: 600;">sudo ufw reload # Internet should be blocked: -weight: 600;">sudo -u jellyfin -weight: 500;">curl -I -m 10 https://example.com && echo "FAIL" || echo "BLOCKED βœ“" # LAN should work (reaches a local Python HTTP server): -weight: 600;">sudo -u jellyfin -weight: 500;">curl -I -m 10 http://192.168.1.10 && echo "LAN works βœ“" || echo "FAIL" # Internet should be blocked: -weight: 600;">sudo -u jellyfin -weight: 500;">curl -I -m 10 https://example.com && echo "FAIL" || echo "BLOCKED βœ“" # LAN should work (reaches a local Python HTTP server): -weight: 600;">sudo -u jellyfin -weight: 500;">curl -I -m 10 http://192.168.1.10 && echo "LAN works βœ“" || echo "FAIL" # Internet should be blocked: -weight: 600;">sudo -u jellyfin -weight: 500;">curl -I -m 10 https://example.com && echo "FAIL" || echo "BLOCKED βœ“" # LAN should work (reaches a local Python HTTP server): -weight: 600;">sudo -u jellyfin -weight: 500;">curl -I -m 10 http://192.168.1.10 && echo "LAN works βœ“" || echo "FAIL" -weight: 600;">sudo ufw allow from 192.168.0.0/16 to any port 8096 proto tcp -weight: 600;">sudo ufw allow from 192.168.0.0/16 to any port 8096 proto tcp -weight: 600;">sudo ufw allow from 192.168.0.0/16 to any port 8096 proto tcp -weight: 600;">sudo adduser --system --group --no-create-home --shell /usr/sbin/nologin myservice -weight: 600;">sudo passwd -l myservice id -u myservice # use this UID in rules -weight: 600;">sudo adduser --system --group --no-create-home --shell /usr/sbin/nologin myservice -weight: 600;">sudo passwd -l myservice id -u myservice # use this UID in rules -weight: 600;">sudo adduser --system --group --no-create-home --shell /usr/sbin/nologin myservice -weight: 600;">sudo passwd -l myservice id -u myservice # use this UID in rules dpkg-divert dpkg-divert # 1. Create the group -weight: 600;">sudo groupadd -f no-internet getent group no-internet # note the GID # Add your user to the group so 'sg' doesn't prompt for a password -weight: 600;">sudo usermod -aG no-internet $USER # 2. Divert the real binary to a new location -weight: 600;">sudo mkdir -p /usr/lib/chromium -weight: 600;">sudo dpkg-divert --local --add --rename \ --divert /usr/lib/chromium/chromium.distrib /usr/bin/chromium # 3. Reinstall so the diverted file lands at the new path -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install --reinstall chromium # 4. Lock down the real binary -weight: 600;">sudo chown root:no-internet /usr/lib/chromium/chromium.distrib -weight: 600;">sudo chmod 0750 /usr/lib/chromium/chromium.distrib # 5. Put a shell wrapper at the original path -weight: 600;">sudo tee /usr/bin/chromium > /dev/null <<'EOF' #!/bin/bash exec sg no-internet /usr/lib/chromium/chromium.distrib "$@" EOF -weight: 600;">sudo chmod 0755 /usr/bin/chromium # 1. Create the group -weight: 600;">sudo groupadd -f no-internet getent group no-internet # note the GID # Add your user to the group so 'sg' doesn't prompt for a password -weight: 600;">sudo usermod -aG no-internet $USER # 2. Divert the real binary to a new location -weight: 600;">sudo mkdir -p /usr/lib/chromium -weight: 600;">sudo dpkg-divert --local --add --rename \ --divert /usr/lib/chromium/chromium.distrib /usr/bin/chromium # 3. Reinstall so the diverted file lands at the new path -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install --reinstall chromium # 4. Lock down the real binary -weight: 600;">sudo chown root:no-internet /usr/lib/chromium/chromium.distrib -weight: 600;">sudo chmod 0750 /usr/lib/chromium/chromium.distrib # 5. Put a shell wrapper at the original path -weight: 600;">sudo tee /usr/bin/chromium > /dev/null <<'EOF' #!/bin/bash exec sg no-internet /usr/lib/chromium/chromium.distrib "$@" EOF -weight: 600;">sudo chmod 0755 /usr/bin/chromium # 1. Create the group -weight: 600;">sudo groupadd -f no-internet getent group no-internet # note the GID # Add your user to the group so 'sg' doesn't prompt for a password -weight: 600;">sudo usermod -aG no-internet $USER # 2. Divert the real binary to a new location -weight: 600;">sudo mkdir -p /usr/lib/chromium -weight: 600;">sudo dpkg-divert --local --add --rename \ --divert /usr/lib/chromium/chromium.distrib /usr/bin/chromium # 3. Reinstall so the diverted file lands at the new path -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install --reinstall chromium # 4. Lock down the real binary -weight: 600;">sudo chown root:no-internet /usr/lib/chromium/chromium.distrib -weight: 600;">sudo chmod 0750 /usr/lib/chromium/chromium.distrib # 5. Put a shell wrapper at the original path -weight: 600;">sudo tee /usr/bin/chromium > /dev/null <<'EOF' #!/bin/bash exec sg no-internet /usr/lib/chromium/chromium.distrib "$@" EOF -weight: 600;">sudo chmod 0755 /usr/bin/chromium /tmp/sg-wrapper.c /* sg-wrapper.c β€” execv /bin/sg no-internet -- /usr/lib/chromium/chromium.distrib */ #define _GNU_SOURCE #include <errno.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> int main(int argc, char *argv[]) { const char *group = "no-internet"; const char *sg_path = "/bin/sg"; const char *real_binary = "/usr/lib/chromium/chromium.distrib"; int extra = argc - 1; /* count: sg_path + group + "--" + real_binary + extra_args + NULL */ int sg_argc = 1 + 1 + 1 + 1 + extra + 1; char **sg_argv = calloc(sg_argc, sizeof(char *)); if (!sg_argv) { fprintf(stderr, "calloc failed\n"); return 127; } int i = 0; sg_argv[i++] = (char *)sg_path; sg_argv[i++] = (char *)group; sg_argv[i++] = (char *)"--"; sg_argv[i++] = (char *)real_binary; for (int j = 1; j < argc; ++j) sg_argv[i++] = argv[j]; sg_argv[i] = NULL; execv(sg_path, sg_argv); fprintf(stderr, "execv(%s) failed: %s\n", sg_path, strerror(errno)); /* free is technically unreachable if execv succeeds, but kept for completeness */ free(sg_argv); return 126; } /* sg-wrapper.c β€” execv /bin/sg no-internet -- /usr/lib/chromium/chromium.distrib */ #define _GNU_SOURCE #include <errno.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> int main(int argc, char *argv[]) { const char *group = "no-internet"; const char *sg_path = "/bin/sg"; const char *real_binary = "/usr/lib/chromium/chromium.distrib"; int extra = argc - 1; /* count: sg_path + group + "--" + real_binary + extra_args + NULL */ int sg_argc = 1 + 1 + 1 + 1 + extra + 1; char **sg_argv = calloc(sg_argc, sizeof(char *)); if (!sg_argv) { fprintf(stderr, "calloc failed\n"); return 127; } int i = 0; sg_argv[i++] = (char *)sg_path; sg_argv[i++] = (char *)group; sg_argv[i++] = (char *)"--"; sg_argv[i++] = (char *)real_binary; for (int j = 1; j < argc; ++j) sg_argv[i++] = argv[j]; sg_argv[i] = NULL; execv(sg_path, sg_argv); fprintf(stderr, "execv(%s) failed: %s\n", sg_path, strerror(errno)); /* free is technically unreachable if execv succeeds, but kept for completeness */ free(sg_argv); return 126; } /* sg-wrapper.c β€” execv /bin/sg no-internet -- /usr/lib/chromium/chromium.distrib */ #define _GNU_SOURCE #include <errno.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> int main(int argc, char *argv[]) { const char *group = "no-internet"; const char *sg_path = "/bin/sg"; const char *real_binary = "/usr/lib/chromium/chromium.distrib"; int extra = argc - 1; /* count: sg_path + group + "--" + real_binary + extra_args + NULL */ int sg_argc = 1 + 1 + 1 + 1 + extra + 1; char **sg_argv = calloc(sg_argc, sizeof(char *)); if (!sg_argv) { fprintf(stderr, "calloc failed\n"); return 127; } int i = 0; sg_argv[i++] = (char *)sg_path; sg_argv[i++] = (char *)group; sg_argv[i++] = (char *)"--"; sg_argv[i++] = (char *)real_binary; for (int j = 1; j < argc; ++j) sg_argv[i++] = argv[j]; sg_argv[i] = NULL; execv(sg_path, sg_argv); fprintf(stderr, "execv(%s) failed: %s\n", sg_path, strerror(errno)); /* free is technically unreachable if execv succeeds, but kept for completeness */ free(sg_argv); return 126; } gcc -O2 -s -o /tmp/sg-wrapper /tmp/sg-wrapper.c -weight: 600;">sudo mv /tmp/sg-wrapper /usr/bin/chromium -weight: 600;">sudo chown root:no-internet /usr/bin/chromium -weight: 600;">sudo chmod 2751 /usr/bin/chromium # setgid(2) + rwx(7) + r-x(5) + --x(1) gcc -O2 -s -o /tmp/sg-wrapper /tmp/sg-wrapper.c -weight: 600;">sudo mv /tmp/sg-wrapper /usr/bin/chromium -weight: 600;">sudo chown root:no-internet /usr/bin/chromium -weight: 600;">sudo chmod 2751 /usr/bin/chromium # setgid(2) + rwx(7) + r-x(5) + --x(1) gcc -O2 -s -o /tmp/sg-wrapper /tmp/sg-wrapper.c -weight: 600;">sudo mv /tmp/sg-wrapper /usr/bin/chromium -weight: 600;">sudo chown root:no-internet /usr/bin/chromium -weight: 600;">sudo chmod 2751 /usr/bin/chromium # setgid(2) + rwx(7) + r-x(5) + --x(1) -weight: 500;">apt -weight: 500;">upgrade # Tell dpkg to enforce ownership/permissions -weight: 600;">sudo dpkg-statoverride --add root no-internet 0750 /usr/lib/chromium/chromium.distrib # Create a script that reapplies permissions -weight: 600;">sudo tee /usr/local/sbin/reapply-noinet.sh > /dev/null <<'EOF' #!/usr/bin/env bash set -euo pipefail GROUP=no-internet [ -e /usr/bin/chromium ] && chown root:$GROUP /usr/bin/chromium && chmod 2751 /usr/bin/chromium || true [ -e /usr/lib/chromium/chromium.distrib ] && chown root:$GROUP /usr/lib/chromium/chromium.distrib && chmod 0750 /usr/lib/chromium/chromium.distrib || true EOF -weight: 600;">sudo chmod 755 /usr/local/sbin/reapply-noinet.sh # Hook it into APT so it runs after every package -weight: 500;">update -weight: 600;">sudo tee /etc/-weight: 500;">apt/-weight: 500;">apt.conf.d/99-reapply-noinet > /dev/null <<'EOF' DPkg::Post-Invoke {"[ -x /usr/local/sbin/reapply-noinet.sh ] && /usr/local/sbin/reapply-noinet.sh";}; EOF # Tell dpkg to enforce ownership/permissions -weight: 600;">sudo dpkg-statoverride --add root no-internet 0750 /usr/lib/chromium/chromium.distrib # Create a script that reapplies permissions -weight: 600;">sudo tee /usr/local/sbin/reapply-noinet.sh > /dev/null <<'EOF' #!/usr/bin/env bash set -euo pipefail GROUP=no-internet [ -e /usr/bin/chromium ] && chown root:$GROUP /usr/bin/chromium && chmod 2751 /usr/bin/chromium || true [ -e /usr/lib/chromium/chromium.distrib ] && chown root:$GROUP /usr/lib/chromium/chromium.distrib && chmod 0750 /usr/lib/chromium/chromium.distrib || true EOF -weight: 600;">sudo chmod 755 /usr/local/sbin/reapply-noinet.sh # Hook it into APT so it runs after every package -weight: 500;">update -weight: 600;">sudo tee /etc/-weight: 500;">apt/-weight: 500;">apt.conf.d/99-reapply-noinet > /dev/null <<'EOF' DPkg::Post-Invoke {"[ -x /usr/local/sbin/reapply-noinet.sh ] && /usr/local/sbin/reapply-noinet.sh";}; EOF # Tell dpkg to enforce ownership/permissions -weight: 600;">sudo dpkg-statoverride --add root no-internet 0750 /usr/lib/chromium/chromium.distrib # Create a script that reapplies permissions -weight: 600;">sudo tee /usr/local/sbin/reapply-noinet.sh > /dev/null <<'EOF' #!/usr/bin/env bash set -euo pipefail GROUP=no-internet [ -e /usr/bin/chromium ] && chown root:$GROUP /usr/bin/chromium && chmod 2751 /usr/bin/chromium || true [ -e /usr/lib/chromium/chromium.distrib ] && chown root:$GROUP /usr/lib/chromium/chromium.distrib && chmod 0750 /usr/lib/chromium/chromium.distrib || true EOF -weight: 600;">sudo chmod 755 /usr/local/sbin/reapply-noinet.sh # Hook it into APT so it runs after every package -weight: 500;">update -weight: 600;">sudo tee /etc/-weight: 500;">apt/-weight: 500;">apt.conf.d/99-reapply-noinet > /dev/null <<'EOF' DPkg::Post-Invoke {"[ -x /usr/local/sbin/reapply-noinet.sh ] && /usr/local/sbin/reapply-noinet.sh";}; EOF -weight: 600;">sudo rm -f /usr/bin/chromium -weight: 600;">sudo dpkg-divert ---weight: 500;">remove --rename /usr/bin/chromium -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install --reinstall chromium -weight: 600;">sudo rm -f /usr/bin/chromium -weight: 600;">sudo dpkg-divert ---weight: 500;">remove --rename /usr/bin/chromium -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install --reinstall chromium -weight: 600;">sudo rm -f /usr/bin/chromium -weight: 600;">sudo dpkg-divert ---weight: 500;">remove --rename /usr/bin/chromium -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install --reinstall chromium GID=1001 # your no-internet group ID -weight: 600;">sudo iptables -I OUTPUT 1 -m owner --gid-owner $GID -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT -weight: 600;">sudo iptables -I OUTPUT 2 -m owner --gid-owner $GID -d 127.0.0.0/8 -j ACCEPT -weight: 600;">sudo iptables -I OUTPUT 3 -m owner --gid-owner $GID -d 10.0.0.0/8 -j ACCEPT -weight: 600;">sudo iptables -I OUTPUT 4 -m owner --gid-owner $GID -d 172.16.0.0/12 -j ACCEPT -weight: 600;">sudo iptables -I OUTPUT 5 -m owner --gid-owner $GID -d 192.168.0.0/16 -j ACCEPT -weight: 600;">sudo iptables -A OUTPUT -m owner --gid-owner $GID -j LOG --log-prefix "NOINTERNET: " -weight: 600;">sudo iptables -A OUTPUT -m owner --gid-owner $GID -j REJECT GID=1001 # your no-internet group ID -weight: 600;">sudo iptables -I OUTPUT 1 -m owner --gid-owner $GID -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT -weight: 600;">sudo iptables -I OUTPUT 2 -m owner --gid-owner $GID -d 127.0.0.0/8 -j ACCEPT -weight: 600;">sudo iptables -I OUTPUT 3 -m owner --gid-owner $GID -d 10.0.0.0/8 -j ACCEPT -weight: 600;">sudo iptables -I OUTPUT 4 -m owner --gid-owner $GID -d 172.16.0.0/12 -j ACCEPT -weight: 600;">sudo iptables -I OUTPUT 5 -m owner --gid-owner $GID -d 192.168.0.0/16 -j ACCEPT -weight: 600;">sudo iptables -A OUTPUT -m owner --gid-owner $GID -j LOG --log-prefix "NOINTERNET: " -weight: 600;">sudo iptables -A OUTPUT -m owner --gid-owner $GID -j REJECT GID=1001 # your no-internet group ID -weight: 600;">sudo iptables -I OUTPUT 1 -m owner --gid-owner $GID -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT -weight: 600;">sudo iptables -I OUTPUT 2 -m owner --gid-owner $GID -d 127.0.0.0/8 -j ACCEPT -weight: 600;">sudo iptables -I OUTPUT 3 -m owner --gid-owner $GID -d 10.0.0.0/8 -j ACCEPT -weight: 600;">sudo iptables -I OUTPUT 4 -m owner --gid-owner $GID -d 172.16.0.0/12 -j ACCEPT -weight: 600;">sudo iptables -I OUTPUT 5 -m owner --gid-owner $GID -d 192.168.0.0/16 -j ACCEPT -weight: 600;">sudo iptables -A OUTPUT -m owner --gid-owner $GID -j LOG --log-prefix "NOINTERNET: " -weight: 600;">sudo iptables -A OUTPUT -m owner --gid-owner $GID -j REJECT -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install iptables-persistent -weight: 600;">sudo netfilter-persistent save -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install iptables-persistent -weight: 600;">sudo netfilter-persistent save -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install iptables-persistent -weight: 600;">sudo netfilter-persistent save /etc/nftables.conf table inet lanlock { chain output { type filter hook output priority 0; meta skgid 1001 ct state related,established accept meta skgid 1001 ip daddr 127.0.0.0/8 accept meta skgid 1001 ip daddr 10.0.0.0/8 accept meta skgid 1001 ip daddr 172.16.0.0/12 accept meta skgid 1001 ip daddr 192.168.0.0/16 accept meta skgid 1001 ip6 daddr ::1 accept meta skgid 1001 ip6 daddr fe80::/10 accept meta skgid 1001 counter log prefix "NOINTERNET: " meta skgid 1001 drop } } table inet lanlock { chain output { type filter hook output priority 0; meta skgid 1001 ct state related,established accept meta skgid 1001 ip daddr 127.0.0.0/8 accept meta skgid 1001 ip daddr 10.0.0.0/8 accept meta skgid 1001 ip daddr 172.16.0.0/12 accept meta skgid 1001 ip daddr 192.168.0.0/16 accept meta skgid 1001 ip6 daddr ::1 accept meta skgid 1001 ip6 daddr fe80::/10 accept meta skgid 1001 counter log prefix "NOINTERNET: " meta skgid 1001 drop } } table inet lanlock { chain output { type filter hook output priority 0; meta skgid 1001 ct state related,established accept meta skgid 1001 ip daddr 127.0.0.0/8 accept meta skgid 1001 ip daddr 10.0.0.0/8 accept meta skgid 1001 ip daddr 172.16.0.0/12 accept meta skgid 1001 ip daddr 192.168.0.0/16 accept meta skgid 1001 ip6 daddr ::1 accept meta skgid 1001 ip6 daddr fe80::/10 accept meta skgid 1001 counter log prefix "NOINTERNET: " meta skgid 1001 drop } } -weight: 600;">sudo nft -f /etc/nftables.conf -weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">enable --now nftables -weight: 600;">sudo nft -f /etc/nftables.conf -weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">enable --now nftables -weight: 600;">sudo nft -f /etc/nftables.conf -weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">enable --now nftables --gid-owner sg no-internet ... no-internet /usr/lib/chromium/chromium.distrib no-internet no-internet strings /usr/bin/chromium /proc/PID/exe # Create a restricted user -weight: 600;">sudo adduser --disabled-password --gecos "" --shell /usr/sbin/nologin chromium-user -weight: 600;">sudo passwd -l chromium-user id -u chromium-user # use this UID in UFW rules (same format as Option C) # Allow X11 display access xhost +SI:localuser:chromium-user # Launch -weight: 600;">sudo -u chromium-user chromium # Create a restricted user -weight: 600;">sudo adduser --disabled-password --gecos "" --shell /usr/sbin/nologin chromium-user -weight: 600;">sudo passwd -l chromium-user id -u chromium-user # use this UID in UFW rules (same format as Option C) # Allow X11 display access xhost +SI:localuser:chromium-user # Launch -weight: 600;">sudo -u chromium-user chromium # Create a restricted user -weight: 600;">sudo adduser --disabled-password --gecos "" --shell /usr/sbin/nologin chromium-user -weight: 600;">sudo passwd -l chromium-user id -u chromium-user # use this UID in UFW rules (same format as Option C) # Allow X11 display access xhost +SI:localuser:chromium-user # Launch -weight: 600;">sudo -u chromium-user chromium -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install firejail # No network at all β€” this works reliably firejail --net=none chromium -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install firejail # No network at all β€” this works reliably firejail --net=none chromium -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install firejail # No network at all β€” this works reliably firejail --net=none chromium firejail --net=none firejail --netfilter=/etc/firejail/lan-only.net chromium firejail --netfilter=/etc/firejail/lan-only.net chromium firejail --netfilter=/etc/firejail/lan-only.net chromium /etc/firejail/lan-only.net *filter :INPUT ACCEPT [0:0] :FORWARD ACCEPT [0:0] :OUTPUT DROP [0:0] -A OUTPUT -d 127.0.0.0/8 -j ACCEPT -A OUTPUT -d 10.0.0.0/8 -j ACCEPT -A OUTPUT -d 172.16.0.0/12 -j ACCEPT -A OUTPUT -d 192.168.0.0/16 -j ACCEPT -A OUTPUT -m state --state RELATED,ESTABLISHED -j ACCEPT COMMIT *filter :INPUT ACCEPT [0:0] :FORWARD ACCEPT [0:0] :OUTPUT DROP [0:0] -A OUTPUT -d 127.0.0.0/8 -j ACCEPT -A OUTPUT -d 10.0.0.0/8 -j ACCEPT -A OUTPUT -d 172.16.0.0/12 -j ACCEPT -A OUTPUT -d 192.168.0.0/16 -j ACCEPT -A OUTPUT -m state --state RELATED,ESTABLISHED -j ACCEPT COMMIT *filter :INPUT ACCEPT [0:0] :FORWARD ACCEPT [0:0] :OUTPUT DROP [0:0] -A OUTPUT -d 127.0.0.0/8 -j ACCEPT -A OUTPUT -d 10.0.0.0/8 -j ACCEPT -A OUTPUT -d 172.16.0.0/12 -j ACCEPT -A OUTPUT -d 192.168.0.0/16 -j ACCEPT -A OUTPUT -m state --state RELATED,ESTABLISHED -j ACCEPT COMMIT # Create a namespace with no external network -weight: 600;">sudo ip netns add no-inet # Run the app inside it -weight: 600;">sudo ip netns exec no-inet -weight: 600;">sudo -u $USER chromium # Optional: Add LAN-only access via a veth pair -weight: 600;">sudo ip link add veth-host type veth peer name veth-jail -weight: 600;">sudo ip link set veth-jail netns no-inet -weight: 600;">sudo ip addr add 192.168.100.1/24 dev veth-host -weight: 600;">sudo ip link set veth-host up -weight: 600;">sudo ip netns exec no-inet ip addr add 192.168.100.2/24 dev veth-jail -weight: 600;">sudo ip netns exec no-inet ip link set veth-jail up -weight: 600;">sudo ip netns exec no-inet ip link set lo up # Create a namespace with no external network -weight: 600;">sudo ip netns add no-inet # Run the app inside it -weight: 600;">sudo ip netns exec no-inet -weight: 600;">sudo -u $USER chromium # Optional: Add LAN-only access via a veth pair -weight: 600;">sudo ip link add veth-host type veth peer name veth-jail -weight: 600;">sudo ip link set veth-jail netns no-inet -weight: 600;">sudo ip addr add 192.168.100.1/24 dev veth-host -weight: 600;">sudo ip link set veth-host up -weight: 600;">sudo ip netns exec no-inet ip addr add 192.168.100.2/24 dev veth-jail -weight: 600;">sudo ip netns exec no-inet ip link set veth-jail up -weight: 600;">sudo ip netns exec no-inet ip link set lo up # Create a namespace with no external network -weight: 600;">sudo ip netns add no-inet # Run the app inside it -weight: 600;">sudo ip netns exec no-inet -weight: 600;">sudo -u $USER chromium # Optional: Add LAN-only access via a veth pair -weight: 600;">sudo ip link add veth-host type veth peer name veth-jail -weight: 600;">sudo ip link set veth-jail netns no-inet -weight: 600;">sudo ip addr add 192.168.100.1/24 dev veth-host -weight: 600;">sudo ip link set veth-host up -weight: 600;">sudo ip netns exec no-inet ip addr add 192.168.100.2/24 dev veth-jail -weight: 600;">sudo ip netns exec no-inet ip link set veth-jail up -weight: 600;">sudo ip netns exec no-inet ip link set lo up -weight: 600;">sudo groupadd -f no-internet :ufw-before-output -weight: 600;">sudo ufw reload -weight: 600;">sudo iptables-restore --test < /etc/ufw/before.rules iptables-persistent -weight: 600;">sudo -weight: 500;">apt -weight: 500;">remove iptables-persistent readlink -f $(which app) flatpak override --user --unshare=network com.app.Name snap connections app-name snap disconnect app-name:network systemd-resolved 127.0.0.0/8 # 1. Group exists and GID is correct? getent group no-internet # Expected: no-internet:x:<GID>: # 2. Service UID correct? (Option C only) id -u jellyfin # Expected: numeric UID, e.g., 107 # 3. File ownership and permissions correct? (Options B/D) stat -c "%n: %U %G %a" /usr/lib/chromium/chromium.distrib /usr/bin/chromium # Expected: real binary β†’ root:no-internet 0750, wrapper β†’ per your policy # 4. Running processes have correct EGID/UID? ps -eo pid,ppid,uid,euid,gid,egid,cmd | egrep 'chromium|jellyfin|firefox' # Look for: EGID == no-internet GID (Options A/B/D) or UID == -weight: 500;">service UID (Option C) # 5. Internet blocked? sg no-internet -c '-weight: 500;">curl -I -m 10 https://example.com' && echo "FAIL" || echo "BLOCKED βœ“" # For services: -weight: 600;">sudo -u jellyfin -weight: 500;">curl -I -m 10 https://example.com && echo "FAIL" || echo "BLOCKED βœ“" # 6. LAN still works? sg no-internet -c '-weight: 500;">curl -I -m 10 http://192.168.1.1' && echo "LAN works βœ“" || echo "FAIL" # 7. Check firewall logs (if LOG rules added) -weight: 600;">sudo journalctl -k --since "10 minutes ago" | grep -i 'Blocked\|NOINTERNET' # 1. Group exists and GID is correct? getent group no-internet # Expected: no-internet:x:<GID>: # 2. Service UID correct? (Option C only) id -u jellyfin # Expected: numeric UID, e.g., 107 # 3. File ownership and permissions correct? (Options B/D) stat -c "%n: %U %G %a" /usr/lib/chromium/chromium.distrib /usr/bin/chromium # Expected: real binary β†’ root:no-internet 0750, wrapper β†’ per your policy # 4. Running processes have correct EGID/UID? ps -eo pid,ppid,uid,euid,gid,egid,cmd | egrep 'chromium|jellyfin|firefox' # Look for: EGID == no-internet GID (Options A/B/D) or UID == -weight: 500;">service UID (Option C) # 5. Internet blocked? sg no-internet -c '-weight: 500;">curl -I -m 10 https://example.com' && echo "FAIL" || echo "BLOCKED βœ“" # For services: -weight: 600;">sudo -u jellyfin -weight: 500;">curl -I -m 10 https://example.com && echo "FAIL" || echo "BLOCKED βœ“" # 6. LAN still works? sg no-internet -c '-weight: 500;">curl -I -m 10 http://192.168.1.1' && echo "LAN works βœ“" || echo "FAIL" # 7. Check firewall logs (if LOG rules added) -weight: 600;">sudo journalctl -k --since "10 minutes ago" | grep -i 'Blocked\|NOINTERNET' # 1. Group exists and GID is correct? getent group no-internet # Expected: no-internet:x:<GID>: # 2. Service UID correct? (Option C only) id -u jellyfin # Expected: numeric UID, e.g., 107 # 3. File ownership and permissions correct? (Options B/D) stat -c "%n: %U %G %a" /usr/lib/chromium/chromium.distrib /usr/bin/chromium # Expected: real binary β†’ root:no-internet 0750, wrapper β†’ per your policy # 4. Running processes have correct EGID/UID? ps -eo pid,ppid,uid,euid,gid,egid,cmd | egrep 'chromium|jellyfin|firefox' # Look for: EGID == no-internet GID (Options A/B/D) or UID == -weight: 500;">service UID (Option C) # 5. Internet blocked? sg no-internet -c '-weight: 500;">curl -I -m 10 https://example.com' && echo "FAIL" || echo "BLOCKED βœ“" # For services: -weight: 600;">sudo -u jellyfin -weight: 500;">curl -I -m 10 https://example.com && echo "FAIL" || echo "BLOCKED βœ“" # 6. LAN still works? sg no-internet -c '-weight: 500;">curl -I -m 10 http://192.168.1.1' && echo "LAN works βœ“" || echo "FAIL" # 7. Check firewall logs (if LOG rules added) -weight: 600;">sudo journalctl -k --since "10 minutes ago" | grep -i 'Blocked\|NOINTERNET' # Restore UFW backups -weight: 600;">sudo cp /root/before.rules.bak /etc/ufw/before.rules -weight: 600;">sudo cp /root/before6.rules.bak /etc/ufw/before6.rules -weight: 600;">sudo ufw reload # If you need immediate connectivity recovery -weight: 600;">sudo iptables -I OUTPUT 1 -m owner --gid-owner <GID> -j ACCEPT # Remove when fixed: -weight: 600;">sudo iptables -D OUTPUT -m owner --gid-owner <GID> -j ACCEPT # Last resort β€” -weight: 500;">disable the entire firewall -weight: 600;">sudo ufw -weight: 500;">disable # Fix your rules, then: -weight: 600;">sudo ufw -weight: 500;">enable # Undo dpkg-divert (Option D) -weight: 600;">sudo dpkg-divert ---weight: 500;">remove --rename /usr/bin/chromium -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install --reinstall chromium # Restore UFW backups -weight: 600;">sudo cp /root/before.rules.bak /etc/ufw/before.rules -weight: 600;">sudo cp /root/before6.rules.bak /etc/ufw/before6.rules -weight: 600;">sudo ufw reload # If you need immediate connectivity recovery -weight: 600;">sudo iptables -I OUTPUT 1 -m owner --gid-owner <GID> -j ACCEPT # Remove when fixed: -weight: 600;">sudo iptables -D OUTPUT -m owner --gid-owner <GID> -j ACCEPT # Last resort β€” -weight: 500;">disable the entire firewall -weight: 600;">sudo ufw -weight: 500;">disable # Fix your rules, then: -weight: 600;">sudo ufw -weight: 500;">enable # Undo dpkg-divert (Option D) -weight: 600;">sudo dpkg-divert ---weight: 500;">remove --rename /usr/bin/chromium -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install --reinstall chromium # Restore UFW backups -weight: 600;">sudo cp /root/before.rules.bak /etc/ufw/before.rules -weight: 600;">sudo cp /root/before6.rules.bak /etc/ufw/before6.rules -weight: 600;">sudo ufw reload # If you need immediate connectivity recovery -weight: 600;">sudo iptables -I OUTPUT 1 -m owner --gid-owner <GID> -j ACCEPT # Remove when fixed: -weight: 600;">sudo iptables -D OUTPUT -m owner --gid-owner <GID> -j ACCEPT # Last resort β€” -weight: 500;">disable the entire firewall -weight: 600;">sudo ufw -weight: 500;">disable # Fix your rules, then: -weight: 600;">sudo ufw -weight: 500;">enable # Undo dpkg-divert (Option D) -weight: 600;">sudo dpkg-divert ---weight: 500;">remove --rename /usr/bin/chromium -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install --reinstall chromium ufw -weight: 500;">disable - Mark the app's processes with a specific UID or GID - Write firewall rules that allow that UID/GID to reach LAN addresses but reject everything else - RELATED,ESTABLISHED β€” Don't break existing connections mid-stream - Loopback (127.x) β€” App can still talk to localhost - LAN ranges (10.x, 172.16.x, 192.168.x) β€” App can reach your home network - LOG β€” Audit blocked attempts in /var/log/kern.log or journalctl - REJECT β€” Everything else (the actual internet) gets blocked - βœ… Self-discipline β€” you want YOUR OWN app to -weight: 500;">stop phoning home (telemetry, metadata downloads, auto-updates) - βœ… Services and daemons β€” Option C uses UID matching, which IS unbypassable since processes can't change their own UID - βœ… Non-technical users β€” people who won't think to look for the diverted binary - ❌ Technical users who actively want to bypass your restrictions - ❌ Multi-user machines where you're enforcing policy - ❌ Any scenario where "security through obscurity" isn't acceptable - ufw -weight: 500;">disable β†’ restores full internet access - ufw -weight: 500;">enable β†’ locks everything down again