<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Khadirullah Mohammad</title>
    <description>The latest articles on DEV Community by Khadirullah Mohammad (@khadirullah).</description>
    <link>https://dev.to/khadirullah</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3346735%2F45651a40-8081-49c1-a384-9a4fc420add0.png</url>
      <title>DEV Community: Khadirullah Mohammad</title>
      <link>https://dev.to/khadirullah</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/khadirullah"/>
    <language>en</language>
    <item>
      <title>How to Block Internet Access for Any Linux App (While Keeping LAN)</title>
      <dc:creator>Khadirullah Mohammad</dc:creator>
      <pubDate>Wed, 08 Apr 2026 12:55:49 +0000</pubDate>
      <link>https://dev.to/khadirullah/how-to-block-internet-access-for-any-linux-app-while-keeping-lan-5g17</link>
      <guid>https://dev.to/khadirullah/how-to-block-internet-access-for-any-linux-app-while-keeping-lan-5g17</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;This guide shows you how to &lt;strong&gt;block outbound internet for any specific app on Linux&lt;/strong&gt; while keeping localhost and your home LAN fully functional.&lt;/p&gt;

&lt;p&gt;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 &lt;strong&gt;fundamental security flaw&lt;/strong&gt; that most guides never mention — and what to use instead when it actually matters.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🔥 &lt;strong&gt;Safety First: Take a Snapshot!&lt;/strong&gt;&lt;br&gt;
You are modifying core network firewall rules. A simple typo can easily break your internet connection or lock you out of your server. &lt;strong&gt;It is highly recommended to take a VM/System Snapshot before starting.&lt;/strong&gt; If a snapshot is not possible, please &lt;a href="https://khadirullah.com/blog/block-internet-linux-apps/#before-you-start-back-up-everything" rel="noopener noreferrer"&gt;take a manual backup of your UFW rules&lt;/a&gt; first. Reverting a snapshot takes 10 seconds; troubleshooting a broken firewall can take hours.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Why UFW?
&lt;/h3&gt;

&lt;p&gt;You might wonder why this guide uses UFW instead of raw nftables or iptables. The answer is simple: &lt;strong&gt;safety for beginners&lt;/strong&gt;. If something goes wrong — you accidentally lock yourself out of the network, or an app stops working — you can just run &lt;code&gt;sudo ufw disable&lt;/code&gt; or even &lt;code&gt;sudo apt remove ufw&lt;/code&gt; 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  How Does This Actually Work?
&lt;/h2&gt;

&lt;p&gt;Every time a process opens a network socket, the Linux kernel stamps it with the process's &lt;strong&gt;UID&lt;/strong&gt; (User ID) and &lt;strong&gt;GID&lt;/strong&gt; (Group ID). The firewall — specifically netfilter, which UFW sits on top of — can inspect those stamps on outgoing packets and decide: &lt;em&gt;accept&lt;/em&gt; or &lt;em&gt;reject&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That's the entire trick:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Mark&lt;/strong&gt; the app's processes with a specific UID or GID&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write firewall rules&lt;/strong&gt; that allow that UID/GID to reach LAN addresses but reject everything else&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For &lt;strong&gt;services&lt;/strong&gt; (Jellyfin, Syncthing), we match by &lt;strong&gt;UID&lt;/strong&gt; because they already run as dedicated users. For &lt;strong&gt;desktop apps&lt;/strong&gt; (Firefox, Chromium), we match by &lt;strong&gt;GID&lt;/strong&gt; using a &lt;code&gt;no-internet&lt;/code&gt; group.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F11uoqgo66cj3qoysdbem.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F11uoqgo66cj3qoysdbem.webp" alt="flow-chart" width="800" height="1494"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Which Approach Should You Use?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your Situation&lt;/th&gt;
&lt;th&gt;Best Option&lt;/th&gt;
&lt;th&gt;Difficulty&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"I just want to test this quickly"&lt;/td&gt;
&lt;td&gt;Option A — Wrapper script&lt;/td&gt;
&lt;td&gt;⭐ Easy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Desktop GUI app (Firefox, KeePassXC)&lt;/td&gt;
&lt;td&gt;Option B — setgid on ELF&lt;/td&gt;
&lt;td&gt;⭐⭐ Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;System service (Jellyfin, Syncthing)&lt;/td&gt;
&lt;td&gt;Option C — UID owner-match&lt;/td&gt;
&lt;td&gt;⭐ Easy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chromium or Electron apps&lt;/td&gt;
&lt;td&gt;Option D — dpkg-divert&lt;/td&gt;
&lt;td&gt;⭐⭐⭐ Advanced&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You don't use UFW&lt;/td&gt;
&lt;td&gt;Option E — Direct iptables/nftables&lt;/td&gt;
&lt;td&gt;⭐⭐ Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Need real enforcement&lt;/td&gt;
&lt;td&gt;Bypass-Proof Alternatives — Firejail / namespaces&lt;/td&gt;
&lt;td&gt;⭐⭐ Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Quick Glossary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Term&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;How to check&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;UID&lt;/td&gt;
&lt;td&gt;User Identifier (numeric)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;id -u username&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GID&lt;/td&gt;
&lt;td&gt;Group Identifier (numeric)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;getent group groupname&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EGID&lt;/td&gt;
&lt;td&gt;Effective GID — the runtime GID the kernel actually uses for socket ownership&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ps -eo egid,egroup,cmd&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UFW&lt;/td&gt;
&lt;td&gt;Uncomplicated Firewall — Debian/Ubuntu frontend for iptables&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sudo ufw status&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Run a command with a different primary group&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sg groupname command&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dpkg-divert&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Debian tool to relocate a package-managed file so your file can sit at the original path&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dpkg-divert --list&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;conntrack&lt;/td&gt;
&lt;td&gt;Connection tracking — lets the firewall allow replies to established connections&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;owner-match&lt;/td&gt;
&lt;td&gt;iptables module that matches packets by the UID/GID of the process that created the socket&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Before You Start: Back Up Everything
&lt;/h2&gt;

&lt;p&gt;If you didn't take a VM or system snapshot, you &lt;strong&gt;must&lt;/strong&gt; back up your current firewall state. Take 30 seconds to save your current rules so you can easily revert them later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables-save &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; ~/iptables.before
&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/ufw_rules_backup
&lt;span class="nb"&gt;sudo cp&lt;/span&gt; /etc/ufw/before.rules ~/ufw_rules_backup/before.rules.backup
&lt;span class="nb"&gt;sudo cp&lt;/span&gt; /etc/ufw/before6.rules ~/ufw_rules_backup/before6.rules.backup
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If anything goes wrong:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo cp&lt;/span&gt; ~/ufw_rules_backup/before.rules.backup /etc/ufw/before.rules
&lt;span class="nb"&gt;sudo cp&lt;/span&gt; ~/ufw_rules_backup/before6.rules.backup /etc/ufw/before6.rules
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw reload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3xxho2fzktb09yytl0gq.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3xxho2fzktb09yytl0gq.webp" alt="Backing up UFW configuration files in the terminal" width="800" height="44"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Firewall Rules (The Core of Everything)
&lt;/h2&gt;

&lt;p&gt;Every option below ends up using the same firewall rules. The only difference is &lt;em&gt;how&lt;/em&gt; you mark the app. Here's what the rules look like — you'll paste these into &lt;code&gt;/etc/ufw/before.rules&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where Exactly to Paste
&lt;/h3&gt;

&lt;p&gt;Open the file and look for the &lt;code&gt;*filter&lt;/code&gt; section at the top:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;*&lt;span class="n"&gt;filter&lt;/span&gt;
:&lt;span class="n"&gt;ufw&lt;/span&gt;-&lt;span class="n"&gt;before&lt;/span&gt;-&lt;span class="n"&gt;input&lt;/span&gt; - [&lt;span class="m"&gt;0&lt;/span&gt;:&lt;span class="m"&gt;0&lt;/span&gt;]
:&lt;span class="n"&gt;ufw&lt;/span&gt;-&lt;span class="n"&gt;before&lt;/span&gt;-&lt;span class="n"&gt;output&lt;/span&gt; - [&lt;span class="m"&gt;0&lt;/span&gt;:&lt;span class="m"&gt;0&lt;/span&gt;]
:&lt;span class="n"&gt;ufw&lt;/span&gt;-&lt;span class="n"&gt;before&lt;/span&gt;-&lt;span class="n"&gt;forward&lt;/span&gt; - [&lt;span class="m"&gt;0&lt;/span&gt;:&lt;span class="m"&gt;0&lt;/span&gt;]
← &lt;span class="n"&gt;YOUR&lt;/span&gt; &lt;span class="n"&gt;RULES&lt;/span&gt; &lt;span class="n"&gt;GO&lt;/span&gt; &lt;span class="n"&gt;HERE&lt;/span&gt;, &lt;span class="n"&gt;right&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt; &lt;span class="n"&gt;these&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcr44ko3wvyak7y68mbb2.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcr44ko3wvyak7y68mbb2.webp" alt="Opening /etc/ufw/before.rules with sudo vim" width="618" height="52"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Your file should initially look like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F38fc4q0qh4syn1jliog0.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F38fc4q0qh4syn1jliog0.webp" alt="The default /etc/ufw/before.rules filter section before any custom rules" width="800" height="411"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  For Desktop Apps (GID Match)
&lt;/h3&gt;

&lt;p&gt;Once you paste your rules into the editor, it should look exactly like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fla1arwm5bykwlhu27tkr.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fla1arwm5bykwlhu27tkr.webp" alt="Inserting the no-internet GID block into the UFW config" width="800" height="382"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Replace &lt;code&gt;GID&lt;/code&gt; with your actual numeric group ID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# --- BEGIN no-internet block (IPv4) ---&lt;/span&gt;
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; GID &lt;span class="nt"&gt;-m&lt;/span&gt; conntrack &lt;span class="nt"&gt;--ctstate&lt;/span&gt; RELATED,ESTABLISHED &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; GID &lt;span class="nt"&gt;-d&lt;/span&gt; 127.0.0.0/8 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; GID &lt;span class="nt"&gt;-d&lt;/span&gt; 10.0.0.0/8 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; GID &lt;span class="nt"&gt;-d&lt;/span&gt; 172.16.0.0/12 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; GID &lt;span class="nt"&gt;-d&lt;/span&gt; 192.168.0.0/16 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; GID &lt;span class="nt"&gt;-j&lt;/span&gt; LOG &lt;span class="nt"&gt;--log-prefix&lt;/span&gt; &lt;span class="s2"&gt;"Blocked noinet: "&lt;/span&gt;
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; GID &lt;span class="nt"&gt;-j&lt;/span&gt; REJECT
&lt;span class="c"&gt;# --- END no-internet block (IPv4) ---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do the same in &lt;code&gt;/etc/ufw/before6.rules&lt;/code&gt; (use &lt;code&gt;ufw6-before-output&lt;/code&gt;, allow &lt;code&gt;::1&lt;/code&gt; and &lt;code&gt;fe80::/10&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# --- BEGIN no-internet block (IPv6) ---&lt;/span&gt;
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw6-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; GID &lt;span class="nt"&gt;-m&lt;/span&gt; conntrack &lt;span class="nt"&gt;--ctstate&lt;/span&gt; RELATED,ESTABLISHED &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw6-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; GID &lt;span class="nt"&gt;-d&lt;/span&gt; ::1 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw6-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; GID &lt;span class="nt"&gt;-d&lt;/span&gt; fe80::/10 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="c"&gt;# Optional: uncomment for mDNS / DLNA / SSDP LAN service discovery&lt;/span&gt;
&lt;span class="c"&gt;# -A ufw6-before-output -m owner --gid-owner GID -d ff00::/8 -j ACCEPT&lt;/span&gt;
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw6-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; GID &lt;span class="nt"&gt;-j&lt;/span&gt; LOG &lt;span class="nt"&gt;--log-prefix&lt;/span&gt; &lt;span class="s2"&gt;"Blocked noinet v6: "&lt;/span&gt;
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw6-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; GID &lt;span class="nt"&gt;-j&lt;/span&gt; REJECT
&lt;span class="c"&gt;# --- END no-internet block (IPv6) ---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  For Services (UID Match — &lt;code&gt;/etc/ufw/before.rules&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;Same structure, but use &lt;code&gt;--uid-owner&lt;/code&gt; with the service's numeric UID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# --- BEGIN service UID block (IPv4) ---&lt;/span&gt;
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--uid-owner&lt;/span&gt; UID &lt;span class="nt"&gt;-m&lt;/span&gt; conntrack &lt;span class="nt"&gt;--ctstate&lt;/span&gt; RELATED,ESTABLISHED &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--uid-owner&lt;/span&gt; UID &lt;span class="nt"&gt;-d&lt;/span&gt; 127.0.0.0/8 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--uid-owner&lt;/span&gt; UID &lt;span class="nt"&gt;-d&lt;/span&gt; 10.0.0.0/8 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--uid-owner&lt;/span&gt; UID &lt;span class="nt"&gt;-d&lt;/span&gt; 172.16.0.0/12 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--uid-owner&lt;/span&gt; UID &lt;span class="nt"&gt;-d&lt;/span&gt; 192.168.0.0/16 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--uid-owner&lt;/span&gt; UID &lt;span class="nt"&gt;-j&lt;/span&gt; LOG &lt;span class="nt"&gt;--log-prefix&lt;/span&gt; &lt;span class="s2"&gt;"Blocked uid: "&lt;/span&gt;
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--uid-owner&lt;/span&gt; UID &lt;span class="nt"&gt;-j&lt;/span&gt; REJECT
&lt;span class="c"&gt;# --- END service UID block (IPv4) ---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in &lt;code&gt;/etc/ufw/before6.rules&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# --- BEGIN service UID block (IPv6) ---&lt;/span&gt;
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw6-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--uid-owner&lt;/span&gt; UID &lt;span class="nt"&gt;-m&lt;/span&gt; conntrack &lt;span class="nt"&gt;--ctstate&lt;/span&gt; RELATED,ESTABLISHED &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw6-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--uid-owner&lt;/span&gt; UID &lt;span class="nt"&gt;-d&lt;/span&gt; ::1 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw6-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--uid-owner&lt;/span&gt; UID &lt;span class="nt"&gt;-d&lt;/span&gt; fe80::/10 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw6-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--uid-owner&lt;/span&gt; UID &lt;span class="nt"&gt;-j&lt;/span&gt; LOG &lt;span class="nt"&gt;--log-prefix&lt;/span&gt; &lt;span class="s2"&gt;"Blocked uid v6: "&lt;/span&gt;
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw6-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--uid-owner&lt;/span&gt; UID &lt;span class="nt"&gt;-j&lt;/span&gt; REJECT
&lt;span class="c"&gt;# --- END service UID block (IPv6) ---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why This Order?
&lt;/h3&gt;

&lt;p&gt;The rules are evaluated top-to-bottom, first match wins:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;RELATED,ESTABLISHED&lt;/strong&gt; — Don't break existing connections mid-stream&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Loopback&lt;/strong&gt; (127.x) — App can still talk to localhost&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LAN ranges&lt;/strong&gt; (10.x, 172.16.x, 192.168.x) — App can reach your home network&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LOG&lt;/strong&gt; — Audit blocked attempts in &lt;code&gt;/var/log/kern.log&lt;/code&gt; or journalctl&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;REJECT&lt;/strong&gt; — Everything else (the actual internet) gets blocked&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Safe Way to Edit
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Warning:&lt;/strong&gt; Always backup your original firewall rules to a safe, persistent location (like your root directory) before editing. Temporary files in &lt;code&gt;/tmp/&lt;/code&gt; are wiped upon every reboot!&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Don't edit the live file directly. Backup, copy to a temp file, edit, test, then apply:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Create a permanent backup&lt;/span&gt;
&lt;span class="nb"&gt;sudo cp&lt;/span&gt; /etc/ufw/before.rules /root/before.rules.backup

&lt;span class="c"&gt;# 2. Copy to a temporary file for editing&lt;/span&gt;
&lt;span class="nb"&gt;sudo cp&lt;/span&gt; /etc/ufw/before.rules /tmp/before.rules.edit
&lt;span class="nb"&gt;sudo &lt;/span&gt;nano /tmp/before.rules.edit                          &lt;span class="c"&gt;# paste your rules&lt;/span&gt;

&lt;span class="c"&gt;# 3. Syntax check (safe, doesn't apply)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables-restore &lt;span class="nt"&gt;--test&lt;/span&gt; &amp;lt; /tmp/before.rules.edit     

&lt;span class="c"&gt;# 4. Apply the rules&lt;/span&gt;
&lt;span class="nb"&gt;sudo mv&lt;/span&gt; /tmp/before.rules.edit /etc/ufw/before.rules
&lt;span class="nb"&gt;sudo chown &lt;/span&gt;root:root /etc/ufw/before.rules
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;644 /etc/ufw/before.rules
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw reload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Option A: Quick Wrapper Script
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Time:&lt;/strong&gt; 2 minutes · &lt;strong&gt;Best for:&lt;/strong&gt; Testing, quick experiments&lt;/p&gt;

&lt;p&gt;This is the fastest way. You create a tiny script that launches any app under a &lt;code&gt;no-internet&lt;/code&gt; group.&lt;/p&gt;

&lt;h3&gt;
  
  
  Setup
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Create the group&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;groupadd &lt;span class="nt"&gt;-f&lt;/span&gt; no-internet
getent group no-internet    &lt;span class="c"&gt;# note the GID (e.g., 1001)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzn1lnpambwa0sjaaxzkl.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzn1lnpambwa0sjaaxzkl.webp" alt="Creating the 'no-internet' group and verifying its GID" width="800" height="195"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Add your user to the group so 'sg' doesn't prompt for a password&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; no-internet &lt;span class="nv"&gt;$USER&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv22uoo738q4iosfzvjzw.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv22uoo738q4iosfzvjzw.webp" alt="Adding the current user to the 'no-internet' group" width="658" height="87"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Create the wrapper script
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /usr/local/bin/no-internet &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
#!/bin/bash
exec sg no-internet "&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="sh"&gt;"
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;755 /usr/local/bin/no-internet
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the GID firewall rules to UFW and reload.&lt;/p&gt;

&lt;h3&gt;
  
  
  Usage
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;no-internet firefox &amp;amp;
no-internet steam &amp;amp;
no-internet keepassxc &amp;amp;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyzamn62mo0vv5m7ia9p3.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyzamn62mo0vv5m7ia9p3.webp" alt="Launching Firefox using our new no-internet wrapper script" width="800" height="46"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Verify It Works
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Should be BLOCKED:&lt;/span&gt;
sg no-internet &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'curl -I -m 10 https://example.com'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAIL"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"BLOCKED ✓"&lt;/span&gt;

&lt;span class="c"&gt;# Should still work:&lt;/span&gt;
sg no-internet &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'curl -I -m 10 http://192.168.1.1'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"LAN works ✓"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAIL"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F57we61s3w3rupp9ztr89.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F57we61s3w3rupp9ztr89.webp" alt="Proof: Firefox trying to reach Google and failing with 'Unable to connect'" width="800" height="409"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Downside:&lt;/strong&gt; If you launch the app from the desktop menu, it won't use the wrapper. You'd need to edit the &lt;code&gt;.desktop&lt;/code&gt; file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; /usr/share/applications/firefox.desktop ~/.local/share/applications/
nano ~/.local/share/applications/firefox.desktop
&lt;span class="c"&gt;# Change: Exec=firefox %u&lt;/span&gt;
&lt;span class="c"&gt;# To:     Exec=/usr/local/bin/no-internet firefox %u&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Option B: setgid on the Binary
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Time:&lt;/strong&gt; 5 minutes · &lt;strong&gt;Best for:&lt;/strong&gt; Desktop apps you always want restricted&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;no-internet&lt;/code&gt; group.&lt;/p&gt;

&lt;h3&gt;
  
  
  Find the Real Binary
&lt;/h3&gt;

&lt;p&gt;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):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;which firefox                              &lt;span class="c"&gt;# might be /usr/bin/firefox&lt;/span&gt;
&lt;span class="nb"&gt;readlink&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;which firefox&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;             &lt;span class="c"&gt;# resolves symlinks&lt;/span&gt;
file &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;readlink&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;which firefox&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   &lt;span class="c"&gt;# should say "ELF 64-bit"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;file&lt;/code&gt; says "shell script" or "Python script", dig deeper — that script calls the real binary somewhere.&lt;/p&gt;

&lt;h3&gt;
  
  
  Apply setgid
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo chown &lt;/span&gt;root:no-internet /path/to/real/elf/binary
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;750 /path/to/real/elf/binary
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;g+s /path/to/real/elf/binary    &lt;span class="c"&gt;# the magic: setgid bit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every process spawned from this binary inherits EGID = &lt;code&gt;no-internet&lt;/code&gt;, which the firewall matches.&lt;/p&gt;

&lt;h3&gt;
  
  
  Verify
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;firefox &amp;amp; &lt;span class="nb"&gt;sleep &lt;/span&gt;1
ps &lt;span class="nt"&gt;-eo&lt;/span&gt; pid,uid,egid,cmd | &lt;span class="nb"&gt;grep &lt;/span&gt;firefox
&lt;span class="c"&gt;# EGID column should show your no-internet GID number&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Rollback
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;g-s /path/to/real/elf/binary
&lt;span class="nb"&gt;sudo chown &lt;/span&gt;root:root /path/to/real/elf/binary
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;755 /path/to/real/elf/binary
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Caveat:&lt;/strong&gt; This doesn't work on Snap or Flatpak apps — they run in sandboxes with their own network stack. For &lt;strong&gt;Flatpak&lt;/strong&gt;, use &lt;a href="https://flathub.org/apps/com.github.tchx84.Flatseal" rel="noopener noreferrer"&gt;Flatseal&lt;/a&gt; (GUI) to toggle off "Network" permissions, or run &lt;code&gt;flatpak override --user --unshare=network com.app.Name&lt;/code&gt;. For &lt;strong&gt;Snap&lt;/strong&gt;, use &lt;code&gt;snap connections app-name&lt;/code&gt; and &lt;code&gt;snap disconnect app-name:network&lt;/code&gt; to revoke the network plug. Or install the app as a native &lt;code&gt;.deb&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Option C: Service UID Match
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Time:&lt;/strong&gt; 3 minutes · &lt;strong&gt;Best for:&lt;/strong&gt; Daemons like Jellyfin, Syncthing, qBittorrent&lt;/p&gt;

&lt;p&gt;Services already run as dedicated system users. You just match their UID in the firewall. This is the &lt;strong&gt;strongest&lt;/strong&gt; of the five options because a service can't change its own UID.&lt;/p&gt;

&lt;h3&gt;
  
  
  Find the UID
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; jellyfin    &lt;span class="c"&gt;# e.g., 112&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmz5ndtbxb7jmwxi3is8u.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmz5ndtbxb7jmwxi3is8u.webp" alt="Checking the numeric UID of the jellyfin service user" width="441" height="94"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Add UID Rules to UFW
&lt;/h3&gt;

&lt;p&gt;Same as the GID rules above, but use &lt;code&gt;--uid-owner 112&lt;/code&gt; instead of &lt;code&gt;--gid-owner&lt;/code&gt;. Paste into &lt;code&gt;before.rules&lt;/code&gt; and &lt;code&gt;before6.rules&lt;/code&gt;, then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw reload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ff6sxxh26sxat6upejs0z.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ff6sxxh26sxat6upejs0z.webp" alt="Firewall successfully reloaded after configuration changes" width="664" height="77"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyo9gvv6jr0mk695w9zmt.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyo9gvv6jr0mk695w9zmt.webp" alt="The final UFW before.rules file with both GID and UID blocks implemented" width="800" height="383"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Test
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Internet should be blocked:&lt;/span&gt;
&lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; jellyfin curl &lt;span class="nt"&gt;-I&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 10 https://example.com &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAIL"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"BLOCKED ✓"&lt;/span&gt;

&lt;span class="c"&gt;# LAN should work (reaches a local Python HTTP server):&lt;/span&gt;
&lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; jellyfin curl &lt;span class="nt"&gt;-I&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 10 http://192.168.1.10 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"LAN works ✓"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAIL"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwbtg30mjgtf621zxsqxs.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwbtg30mjgtf621zxsqxs.gif" alt="Jellyfin service verification: Internet requests are blocked while LAN requests succeed." width="760" height="428"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The Ultimate Proof: LAN vs Internet
&lt;/h3&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1h4jgor6d0d6aynt1c36.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1h4jgor6d0d6aynt1c36.gif" alt="Technical Proof: Internet access (Google) is blocked, while local network access remains fully accessible." width="600" height="338"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  Don't Forget: Allow Incoming on the Service Port
&lt;/h3&gt;

&lt;p&gt;If your UFW default is "deny incoming" (it should be), LAN clients can't reach your service unless you explicitly allow the port:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow from 192.168.0.0/16 to any port 8096 proto tcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  For Custom Services Without a Dedicated User
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;adduser &lt;span class="nt"&gt;--system&lt;/span&gt; &lt;span class="nt"&gt;--group&lt;/span&gt; &lt;span class="nt"&gt;--no-create-home&lt;/span&gt; &lt;span class="nt"&gt;--shell&lt;/span&gt; /usr/sbin/nologin myservice
&lt;span class="nb"&gt;sudo &lt;/span&gt;passwd &lt;span class="nt"&gt;-l&lt;/span&gt; myservice
&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; myservice    &lt;span class="c"&gt;# use this UID in rules&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Option D: dpkg-divert + Wrapper
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Time:&lt;/strong&gt; 15 minutes · &lt;strong&gt;Best for:&lt;/strong&gt; Chromium, Electron, multi-process apps&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; &lt;code&gt;dpkg-divert&lt;/code&gt; 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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The solution: use Debian's &lt;code&gt;dpkg-divert&lt;/code&gt; to relocate the real binary, then put a wrapper at the original path. Every invocation — menu, terminal, child processes — goes through your wrapper.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Full Setup
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Create the group&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;groupadd &lt;span class="nt"&gt;-f&lt;/span&gt; no-internet
getent group no-internet    &lt;span class="c"&gt;# note the GID&lt;/span&gt;

&lt;span class="c"&gt;# Add your user to the group so 'sg' doesn't prompt for a password&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; no-internet &lt;span class="nv"&gt;$USER&lt;/span&gt;

&lt;span class="c"&gt;# 2. Divert the real binary to a new location&lt;/span&gt;
&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /usr/lib/chromium
&lt;span class="nb"&gt;sudo &lt;/span&gt;dpkg-divert &lt;span class="nt"&gt;--local&lt;/span&gt; &lt;span class="nt"&gt;--add&lt;/span&gt; &lt;span class="nt"&gt;--rename&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--divert&lt;/span&gt; /usr/lib/chromium/chromium.distrib /usr/bin/chromium

&lt;span class="c"&gt;# 3. Reinstall so the diverted file lands at the new path&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--reinstall&lt;/span&gt; chromium

&lt;span class="c"&gt;# 4. Lock down the real binary&lt;/span&gt;
&lt;span class="nb"&gt;sudo chown &lt;/span&gt;root:no-internet /usr/lib/chromium/chromium.distrib
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;0750 /usr/lib/chromium/chromium.distrib

&lt;span class="c"&gt;# 5. Put a shell wrapper at the original path&lt;/span&gt;
&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /usr/bin/chromium &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
#!/bin/bash
exec sg no-internet /usr/lib/chromium/chromium.distrib "&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="sh"&gt;"
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;0755 /usr/bin/chromium
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the GID firewall rules, reload UFW, and test.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option D Variant: Compiled C Wrapper
&lt;/h3&gt;

&lt;p&gt;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 &lt;code&gt;strings&lt;/code&gt; will still reveal the path — see Security Limitations below).&lt;/p&gt;

&lt;p&gt;Save as &lt;code&gt;/tmp/sg-wrapper.c&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cm"&gt;/* sg-wrapper.c — execv /bin/sg no-internet -- /usr/lib/chromium/chromium.distrib */&lt;/span&gt;
&lt;span class="cp"&gt;#define _GNU_SOURCE
#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;errno.h&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;
#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;stdio.h&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;
#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;stdlib.h&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;
#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;string.h&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;
#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;unistd.h&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;
&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;argc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;group&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"no-internet"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;sg_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/bin/sg"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;real_binary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/usr/lib/chromium/chromium.distrib"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;extra&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;argc&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="cm"&gt;/* count: sg_path + group + "--" + real_binary + extra_args + NULL */&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;sg_argc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;extra&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;sg_argv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;calloc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sg_argc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;sizeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;sg_argv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;fprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"calloc failed&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;127&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;sg_argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;sg_path&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;sg_argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;sg_argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="s"&gt;"--"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;sg_argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;real_binary&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;argc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;sg_argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="n"&gt;sg_argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="n"&gt;execv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sg_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sg_argv&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;fprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"execv(%s) failed: %s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sg_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;strerror&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errno&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="cm"&gt;/* free is technically unreachable if execv succeeds, but kept for completeness */&lt;/span&gt;
    &lt;span class="n"&gt;free&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sg_argv&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;126&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compile and install:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcc &lt;span class="nt"&gt;-O2&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /tmp/sg-wrapper /tmp/sg-wrapper.c
&lt;span class="nb"&gt;sudo mv&lt;/span&gt; /tmp/sg-wrapper /usr/bin/chromium
&lt;span class="nb"&gt;sudo chown &lt;/span&gt;root:no-internet /usr/bin/chromium
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;2751 /usr/bin/chromium    &lt;span class="c"&gt;# setgid(2) + rwx(7) + r-x(5) + --x(1)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Surviving &lt;code&gt;apt upgrade&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Package updates can overwrite your changes. Protect them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Tell dpkg to enforce ownership/permissions&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dpkg-statoverride &lt;span class="nt"&gt;--add&lt;/span&gt; root no-internet 0750 /usr/lib/chromium/chromium.distrib

&lt;span class="c"&gt;# Create a script that reapplies permissions&lt;/span&gt;
&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /usr/local/sbin/reapply-noinet.sh &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
#!/usr/bin/env bash
set -euo pipefail
GROUP=no-internet
[ -e /usr/bin/chromium ] &amp;amp;&amp;amp; chown root:&lt;/span&gt;&lt;span class="nv"&gt;$GROUP&lt;/span&gt;&lt;span class="sh"&gt; /usr/bin/chromium &amp;amp;&amp;amp; chmod 2751 /usr/bin/chromium || true
[ -e /usr/lib/chromium/chromium.distrib ] &amp;amp;&amp;amp; chown root:&lt;/span&gt;&lt;span class="nv"&gt;$GROUP&lt;/span&gt;&lt;span class="sh"&gt; /usr/lib/chromium/chromium.distrib &amp;amp;&amp;amp; chmod 0750 /usr/lib/chromium/chromium.distrib || true
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;755 /usr/local/sbin/reapply-noinet.sh

&lt;span class="c"&gt;# Hook it into APT so it runs after every package update&lt;/span&gt;
&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/apt/apt.conf.d/99-reapply-noinet &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
DPkg::Post-Invoke {"[ -x /usr/local/sbin/reapply-noinet.sh ] &amp;amp;&amp;amp; /usr/local/sbin/reapply-noinet.sh";};
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Rollback
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /usr/bin/chromium
&lt;span class="nb"&gt;sudo &lt;/span&gt;dpkg-divert &lt;span class="nt"&gt;--remove&lt;/span&gt; &lt;span class="nt"&gt;--rename&lt;/span&gt; /usr/bin/chromium
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--reinstall&lt;/span&gt; chromium
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Option E: Raw iptables / nftables
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Systems that don't use UFW, or if you prefer direct control.&lt;/p&gt;

&lt;h3&gt;
  
  
  iptables
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;GID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1001  &lt;span class="c"&gt;# your no-internet group ID&lt;/span&gt;

&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;-I&lt;/span&gt; OUTPUT 1 &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; &lt;span class="nv"&gt;$GID&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; conntrack &lt;span class="nt"&gt;--ctstate&lt;/span&gt; RELATED,ESTABLISHED &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;-I&lt;/span&gt; OUTPUT 2 &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; &lt;span class="nv"&gt;$GID&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; 127.0.0.0/8 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;-I&lt;/span&gt; OUTPUT 3 &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; &lt;span class="nv"&gt;$GID&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; 10.0.0.0/8 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;-I&lt;/span&gt; OUTPUT 4 &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; &lt;span class="nv"&gt;$GID&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; 172.16.0.0/12 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;-I&lt;/span&gt; OUTPUT 5 &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; &lt;span class="nv"&gt;$GID&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; 192.168.0.0/16 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;-A&lt;/span&gt; OUTPUT &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; &lt;span class="nv"&gt;$GID&lt;/span&gt; &lt;span class="nt"&gt;-j&lt;/span&gt; LOG &lt;span class="nt"&gt;--log-prefix&lt;/span&gt; &lt;span class="s2"&gt;"NOINTERNET: "&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;-A&lt;/span&gt; OUTPUT &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; &lt;span class="nv"&gt;$GID&lt;/span&gt; &lt;span class="nt"&gt;-j&lt;/span&gt; REJECT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Persist with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;iptables-persistent
&lt;span class="nb"&gt;sudo &lt;/span&gt;netfilter-persistent save
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  nftables
&lt;/h3&gt;

&lt;p&gt;Add to &lt;code&gt;/etc/nftables.conf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nft &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/nftables.conf
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; nftables
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The Security Flaw Nobody Talks About
&lt;/h2&gt;

&lt;p&gt;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 &lt;strong&gt;fundamental bypass&lt;/strong&gt; that most guides never mention.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Problem: EGID vs Supplementary Groups
&lt;/h3&gt;

&lt;p&gt;The firewall's &lt;code&gt;--gid-owner&lt;/code&gt; match checks the process's &lt;strong&gt;EGID&lt;/strong&gt; (Effective Group ID) — not its supplementary group list. Here's what that means in practice:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;How the app is launched&lt;/th&gt;
&lt;th&gt;Process EGID&lt;/th&gt;
&lt;th&gt;Firewall matches?&lt;/th&gt;
&lt;th&gt;Internet?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Via wrapper (&lt;code&gt;sg no-internet ...&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;no-internet&lt;/code&gt; (1001)&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;❌ Blocked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Directly (&lt;code&gt;/usr/lib/chromium/chromium.distrib&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;User's primary group (1000)&lt;/td&gt;
&lt;td&gt;❌ No&lt;/td&gt;
&lt;td&gt;✅ &lt;strong&gt;Full access&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When a user runs a binary directly, their &lt;strong&gt;primary group&lt;/strong&gt; becomes the EGID. The &lt;code&gt;no-internet&lt;/code&gt; supplementary group membership is irrelevant to the firewall.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwxm53qmxwg5dvsz1rty0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwxm53qmxwg5dvsz1rty0.png" alt="flow-chart-1" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And there's a catch-22: &lt;code&gt;sg&lt;/code&gt; (which the wrapper uses) requires the user to be a &lt;strong&gt;member&lt;/strong&gt; of the &lt;code&gt;no-internet&lt;/code&gt; group. But if they're a member, they also have permission to execute the &lt;code&gt;chmod 0750&lt;/code&gt; binary directly — bypassing the wrapper entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  "What If I Hide the Binary Path?"
&lt;/h3&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Attempt&lt;/th&gt;
&lt;th&gt;Why it fails&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Compiled C wrapper&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;strings /usr/bin/chromium&lt;/code&gt; reveals the embedded path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Random filename&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ps aux&lt;/code&gt; and &lt;code&gt;/proc/PID/exe&lt;/code&gt; expose it at runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;setgid on the binary itself&lt;/td&gt;
&lt;td&gt;Chromium and Firefox &lt;strong&gt;refuse to run with setgid&lt;/strong&gt; (browser security feature)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  So When IS the GID Approach Good Enough?
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;✅ &lt;strong&gt;Self-discipline&lt;/strong&gt; — you want YOUR OWN app to stop phoning home (telemetry, metadata downloads, auto-updates)&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Services and daemons&lt;/strong&gt; — Option C uses UID matching, which IS unbypassable since processes can't change their own UID&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Non-technical users&lt;/strong&gt; — people who won't think to look for the diverted binary&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  When You Need Something Stronger
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;❌ Technical users who actively want to bypass your restrictions&lt;/li&gt;
&lt;li&gt;❌ Multi-user machines where you're enforcing policy&lt;/li&gt;
&lt;li&gt;❌ Any scenario where "security through obscurity" isn't acceptable&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For those cases, keep reading.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bypass-Proof Alternatives (Not-Tested By Me)
&lt;/h2&gt;

&lt;p&gt;When the GID approach isn't enough, here are three methods that provide real, kernel-enforced isolation.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; 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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Alternative 1: Separate User + UID Match
&lt;/h3&gt;

&lt;p&gt;Run the app as a completely separate user. UID matching &lt;strong&gt;cannot be bypassed&lt;/strong&gt; — a user can't change their own UID.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Create a restricted user&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;adduser &lt;span class="nt"&gt;--disabled-password&lt;/span&gt; &lt;span class="nt"&gt;--gecos&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt; &lt;span class="nt"&gt;--shell&lt;/span&gt; /usr/sbin/nologin chromium-user
&lt;span class="nb"&gt;sudo &lt;/span&gt;passwd &lt;span class="nt"&gt;-l&lt;/span&gt; chromium-user
&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; chromium-user    &lt;span class="c"&gt;# use this UID in UFW rules (same format as Option C)&lt;/span&gt;

&lt;span class="c"&gt;# Allow X11 display access&lt;/span&gt;
xhost +SI:localuser:chromium-user

&lt;span class="c"&gt;# Launch&lt;/span&gt;
&lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; chromium-user chromium
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Tradeoffs:&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Alternative 2: Firejail (Easiest True Isolation)
&lt;/h3&gt;

&lt;p&gt;Firejail uses kernel network namespaces under the hood. No firewall rules needed — the app physically cannot see the external network.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;firejail

&lt;span class="c"&gt;# No network at all — this works reliably&lt;/span&gt;
firejail &lt;span class="nt"&gt;--net&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;none chromium
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;My experience:&lt;/strong&gt; &lt;code&gt;firejail --net=none&lt;/code&gt; works perfectly — the app has zero network access. However, I was &lt;strong&gt;unable to get LAN-only mode working&lt;/strong&gt; using the theoretical setup for reference, but your mileage may vary.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;LAN-only (theoretical):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;firejail &lt;span class="nt"&gt;--netfilter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/etc/firejail/lan-only.net chromium
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create &lt;code&gt;/etc/firejail/lan-only.net&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;*&lt;span class="n"&gt;filter&lt;/span&gt;
:&lt;span class="n"&gt;INPUT&lt;/span&gt; &lt;span class="n"&gt;ACCEPT&lt;/span&gt; [&lt;span class="m"&gt;0&lt;/span&gt;:&lt;span class="m"&gt;0&lt;/span&gt;]
:&lt;span class="n"&gt;FORWARD&lt;/span&gt; &lt;span class="n"&gt;ACCEPT&lt;/span&gt; [&lt;span class="m"&gt;0&lt;/span&gt;:&lt;span class="m"&gt;0&lt;/span&gt;]
:&lt;span class="n"&gt;OUTPUT&lt;/span&gt; &lt;span class="n"&gt;DROP&lt;/span&gt; [&lt;span class="m"&gt;0&lt;/span&gt;:&lt;span class="m"&gt;0&lt;/span&gt;]
-&lt;span class="n"&gt;A&lt;/span&gt; &lt;span class="n"&gt;OUTPUT&lt;/span&gt; -&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="m"&gt;127&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;/&lt;span class="m"&gt;8&lt;/span&gt; -&lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="n"&gt;ACCEPT&lt;/span&gt;
-&lt;span class="n"&gt;A&lt;/span&gt; &lt;span class="n"&gt;OUTPUT&lt;/span&gt; -&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;/&lt;span class="m"&gt;8&lt;/span&gt; -&lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="n"&gt;ACCEPT&lt;/span&gt;
-&lt;span class="n"&gt;A&lt;/span&gt; &lt;span class="n"&gt;OUTPUT&lt;/span&gt; -&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="m"&gt;172&lt;/span&gt;.&lt;span class="m"&gt;16&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;/&lt;span class="m"&gt;12&lt;/span&gt; -&lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="n"&gt;ACCEPT&lt;/span&gt;
-&lt;span class="n"&gt;A&lt;/span&gt; &lt;span class="n"&gt;OUTPUT&lt;/span&gt; -&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="m"&gt;192&lt;/span&gt;.&lt;span class="m"&gt;168&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;/&lt;span class="m"&gt;16&lt;/span&gt; -&lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="n"&gt;ACCEPT&lt;/span&gt;
-&lt;span class="n"&gt;A&lt;/span&gt; &lt;span class="n"&gt;OUTPUT&lt;/span&gt; -&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt; --&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="n"&gt;RELATED&lt;/span&gt;,&lt;span class="n"&gt;ESTABLISHED&lt;/span&gt; -&lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="n"&gt;ACCEPT&lt;/span&gt;
&lt;span class="n"&gt;COMMIT&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Alternative 3: Network Namespaces (Manual, Full Control)
&lt;/h3&gt;

&lt;p&gt;For maximum control, create a network namespace directly. No extra packages needed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Create a namespace with no external network&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip netns add no-inet

&lt;span class="c"&gt;# Run the app inside it&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip netns &lt;span class="nb"&gt;exec &lt;/span&gt;no-inet &lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="nv"&gt;$USER&lt;/span&gt; chromium

&lt;span class="c"&gt;# Optional: Add LAN-only access via a veth pair&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link &lt;/span&gt;add veth-host &lt;span class="nb"&gt;type &lt;/span&gt;veth peer name veth-jail
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link set &lt;/span&gt;veth-jail netns no-inet
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip addr add 192.168.100.1/24 dev veth-host
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link set &lt;/span&gt;veth-host up
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip netns &lt;span class="nb"&gt;exec &lt;/span&gt;no-inet ip addr add 192.168.100.2/24 dev veth-jail
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip netns &lt;span class="nb"&gt;exec &lt;/span&gt;no-inet ip &lt;span class="nb"&gt;link set &lt;/span&gt;veth-jail up
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip netns &lt;span class="nb"&gt;exec &lt;/span&gt;no-inet ip &lt;span class="nb"&gt;link set &lt;/span&gt;lo up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Quick Comparison
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Threat Model&lt;/th&gt;
&lt;th&gt;Best Solution&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Block your own apps from phoning home&lt;/td&gt;
&lt;td&gt;GID wrapper (Option A/D) — simple, good enough&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Block a daemon/service&lt;/td&gt;
&lt;td&gt;UID owner-match (Option C) — unbypassable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Restrict technical/untrusted users&lt;/td&gt;
&lt;td&gt;Separate user + UID match (Alt 1)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;True network sandbox, easy setup&lt;/td&gt;
&lt;td&gt;Firejail (Alt 2)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full manual control, no dependencies&lt;/td&gt;
&lt;td&gt;Network namespace (Alt 3)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enterprise/production&lt;/td&gt;
&lt;td&gt;AppArmor + containers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Troubleshooting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"sg: no such group"&lt;/strong&gt;&lt;br&gt;
→ Group doesn't exist yet. Run &lt;code&gt;sudo groupadd -f no-internet&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Internet is still working after adding rules&lt;/strong&gt;&lt;br&gt;
→ Double-check the numeric UID/GID in your rules matches reality. Make sure you pasted the block right after the &lt;code&gt;:ufw-before-output&lt;/code&gt; line, not at the bottom. Run &lt;code&gt;sudo ufw reload&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;UFW reload fails&lt;/strong&gt;&lt;br&gt;
→ Syntax error in your rules. Test before applying: &lt;code&gt;sudo iptables-restore --test &amp;lt; /etc/ufw/before.rules&lt;/code&gt;. If it fails, restore your backup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It works, but breaks after reboot&lt;/strong&gt;&lt;br&gt;
→ You might have &lt;code&gt;iptables-persistent&lt;/code&gt; installed, which conflicts with UFW. Remove it: &lt;code&gt;sudo apt remove iptables-persistent&lt;/code&gt;. Let UFW handle everything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;setgid isn't working&lt;/strong&gt;&lt;br&gt;
→ You probably applied it to a shell script wrapper, not the real ELF binary. Use &lt;code&gt;readlink -f $(which app)&lt;/code&gt; and &lt;code&gt;file&lt;/code&gt; to find the actual binary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Snap/Flatpak apps are unaffected&lt;/strong&gt;&lt;br&gt;
→ They run in sandboxes with their own network stack. &lt;strong&gt;Flatpak:&lt;/strong&gt; Use Flatseal (GUI) to toggle off "Network" permissions, or run &lt;code&gt;flatpak override --user --unshare=network com.app.Name&lt;/code&gt;. &lt;strong&gt;Snap:&lt;/strong&gt; Use &lt;code&gt;snap connections app-name&lt;/code&gt; and &lt;code&gt;snap disconnect app-name:network&lt;/code&gt; to revoke the network plug. Or install the app as a native &lt;code&gt;.deb&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DNS seems to leak&lt;/strong&gt;&lt;br&gt;
→ &lt;code&gt;systemd-resolved&lt;/code&gt; runs on &lt;code&gt;127.0.0.53&lt;/code&gt;. Since we allow &lt;code&gt;127.0.0.0/8&lt;/code&gt;, DNS resolves even for blocked apps — but the actual connections still get rejected.&lt;/p&gt;


&lt;h2&gt;
  
  
  Testing Checklist
&lt;/h2&gt;

&lt;p&gt;After setting up any option, run through this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Group exists and GID is correct?&lt;/span&gt;
getent group no-internet
&lt;span class="c"&gt;# Expected: no-internet:x:&amp;lt;GID&amp;gt;:&lt;/span&gt;

&lt;span class="c"&gt;# 2. Service UID correct? (Option C only)&lt;/span&gt;
&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; jellyfin
&lt;span class="c"&gt;# Expected: numeric UID, e.g., 107&lt;/span&gt;

&lt;span class="c"&gt;# 3. File ownership and permissions correct? (Options B/D)&lt;/span&gt;
&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"%n: %U %G %a"&lt;/span&gt; /usr/lib/chromium/chromium.distrib /usr/bin/chromium
&lt;span class="c"&gt;# Expected: real binary → root:no-internet 0750, wrapper → per your policy&lt;/span&gt;

&lt;span class="c"&gt;# 4. Running processes have correct EGID/UID?&lt;/span&gt;
ps &lt;span class="nt"&gt;-eo&lt;/span&gt; pid,ppid,uid,euid,gid,egid,cmd | egrep &lt;span class="s1"&gt;'chromium|jellyfin|firefox'&lt;/span&gt;
&lt;span class="c"&gt;# Look for: EGID == no-internet GID (Options A/B/D) or UID == service UID (Option C)&lt;/span&gt;

&lt;span class="c"&gt;# 5. Internet blocked?&lt;/span&gt;
sg no-internet &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'curl -I -m 10 https://example.com'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAIL"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"BLOCKED ✓"&lt;/span&gt;
&lt;span class="c"&gt;# For services:&lt;/span&gt;
&lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; jellyfin curl &lt;span class="nt"&gt;-I&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 10 https://example.com &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAIL"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"BLOCKED ✓"&lt;/span&gt;

&lt;span class="c"&gt;# 6. LAN still works?&lt;/span&gt;
sg no-internet &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'curl -I -m 10 http://192.168.1.1'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"LAN works ✓"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAIL"&lt;/span&gt;

&lt;span class="c"&gt;# 7. Check firewall logs (if LOG rules added)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;journalctl &lt;span class="nt"&gt;-k&lt;/span&gt; &lt;span class="nt"&gt;--since&lt;/span&gt; &lt;span class="s2"&gt;"10 minutes ago"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'Blocked\|NOINTERNET'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Emergency Rollback
&lt;/h2&gt;

&lt;p&gt;If something goes wrong, these commands restore everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Restore UFW backups&lt;/span&gt;
&lt;span class="nb"&gt;sudo cp&lt;/span&gt; /root/before.rules.bak /etc/ufw/before.rules
&lt;span class="nb"&gt;sudo cp&lt;/span&gt; /root/before6.rules.bak /etc/ufw/before6.rules
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw reload

&lt;span class="c"&gt;# If you need immediate connectivity recovery&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;-I&lt;/span&gt; OUTPUT 1 &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; &amp;lt;GID&amp;gt; &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="c"&gt;# Remove when fixed:&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;-D&lt;/span&gt; OUTPUT &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; &amp;lt;GID&amp;gt; &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT

&lt;span class="c"&gt;# Last resort — disable the entire firewall&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw disable
&lt;span class="c"&gt;# Fix your rules, then: sudo ufw enable&lt;/span&gt;

&lt;span class="c"&gt;# Undo dpkg-divert (Option D)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dpkg-divert &lt;span class="nt"&gt;--remove&lt;/span&gt; &lt;span class="nt"&gt;--rename&lt;/span&gt; /usr/bin/chromium
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--reinstall&lt;/span&gt; chromium
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  🎬 Watch it in Action: Full GUI Demo
&lt;/h2&gt;

&lt;p&gt;This walkthrough puts the system to the test using a real-world browser (&lt;strong&gt;Google Chrome&lt;/strong&gt;). Here’s exactly what you’ll see:&lt;/p&gt;

&lt;p&gt;🚫 &lt;strong&gt;The Block&lt;/strong&gt;&lt;br&gt;
Chrome tries to reach Google—and fails instantly while the firewall rules are active.&lt;/p&gt;

&lt;p&gt;🌐 &lt;strong&gt;LAN Routing&lt;/strong&gt;&lt;br&gt;
Despite the block, Chrome successfully loads a local dashboard on your LAN, proving internal traffic still works flawlessly.&lt;/p&gt;

&lt;p&gt;🎛️ &lt;strong&gt;The Control&lt;/strong&gt;&lt;br&gt;
With a simple toggle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ufw disable&lt;/code&gt; → restores full internet access&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ufw enable&lt;/code&gt; → locks everything down again&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Curious to see it all in action? Watch the full high-resolution 75-second demo:&lt;/p&gt;

&lt;p&gt;🔗 &lt;a href="https://khadirullah.com/blog/block-internet-linux-apps/#watch-it-in-action-full-gui-demo" rel="noopener noreferrer"&gt;https://khadirullah.com/blog/block-internet-linux-apps/#watch-it-in-action-full-gui-demo&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;✨ A complete visual walkthrough of the interface, behavior, and control flow—from block to restore.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The GID-based approach (Options A–E) is a clean, elegant way to restrict app networking — and it's &lt;strong&gt;good enough for most personal use cases&lt;/strong&gt;. If you want to stop Jellyfin from downloading metadata, or prevent a game from phoning home, it works perfectly.&lt;/p&gt;

&lt;p&gt;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 &lt;strong&gt;UID matching&lt;/strong&gt; (unbypassable for services), &lt;strong&gt;Firejail&lt;/strong&gt; (easiest for desktop apps), or &lt;strong&gt;network namespaces&lt;/strong&gt; (maximum control).&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Tested on Debian 13 (Trixie) with UFW. Should work on any Debian/Ubuntu-based distro with kernel 4.x+.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>security</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>I Built an Interactive SVG Viewer Because Static Diagrams Deserve Better</title>
      <dc:creator>Khadirullah Mohammad</dc:creator>
      <pubDate>Wed, 11 Feb 2026 13:23:28 +0000</pubDate>
      <link>https://dev.to/khadirullah/i-built-an-interactive-svg-viewer-because-static-diagrams-deserve-better-2aa7</link>
      <guid>https://dev.to/khadirullah/i-built-an-interactive-svg-viewer-because-static-diagrams-deserve-better-2aa7</guid>
      <description>&lt;p&gt;As developers, we love clear documentation. Use Case diagrams, Cloud Architectures, Flowcharts — they are the lifeblood of understanding complex systems. Tools like Mermaid.js, PlantUML, and Draw.io are fantastic for &lt;em&gt;creating&lt;/em&gt; them.&lt;/p&gt;

&lt;p&gt;But &lt;strong&gt;viewing&lt;/strong&gt; them? That experience is often stuck in the past.&lt;/p&gt;

&lt;p&gt;If you export a complex architecture diagram as an SVG and embed it on your docs site, it's just a static image. The text is too small to read, you can't search for that one specific microservice, and if you zoom with your browser, the whole page breaks.&lt;/p&gt;

&lt;p&gt;I looked for a library to solve this. I found &lt;strong&gt;D3.js&lt;/strong&gt; (too complex for just viewing) and &lt;strong&gt;Leaflet&lt;/strong&gt; (too heavy for a diagram). I didn't want to write hundreds of lines of code just to let a user zoom into a flowchart.&lt;/p&gt;

&lt;p&gt;So, I built &lt;strong&gt;DiagView&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9r3v9wzv69qkb2b8w14b.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9r3v9wzv69qkb2b8w14b.gif" title="Zoom, pan, and search in action" alt="DiagView Demo" width="720" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What is DiagView?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;DiagView&lt;/strong&gt; is a feature-rich, interactive wrapper that gives your static SVGs superpowers.&lt;/p&gt;

&lt;p&gt;It is built on top of the excellent &lt;a href="https://github.com/timmywil/panzoom" rel="noopener noreferrer"&gt;panzoom&lt;/a&gt; library, which handles the low-level matrix math for smooth 60fps zooming and panning. But while panzoom gives you the engine, DiagView gives you the entire car.&lt;/p&gt;

&lt;h3&gt;
  
  
  Feature Overview
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;🔍 &lt;strong&gt;Deep Search&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Traverses the SVG DOM to find and highlight matching nodes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;📤 &lt;strong&gt;Multi-Format Export&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;PNG, SVG, PDF, WebP, or copy to clipboard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🎯 &lt;strong&gt;Meeting Mode&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Built-in laser pointer for remote presentations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🔗 &lt;strong&gt;Share Links&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Generate URLs that preserve zoom/pan state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;⌨️ &lt;strong&gt;Keyboard Navigation&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Arrows to pan, +/- to zoom, F to search&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🌗 &lt;strong&gt;Auto-Theming&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Detects light/dark mode automatically&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;📱 &lt;strong&gt;Mobile-First Touch&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Pinch-to-zoom, double-tap to reset&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The Landscape: Why Wasn't This Already Solved?
&lt;/h2&gt;

&lt;p&gt;Before writing any code, I scoured npm and GitHub. Here's what I found:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;D3.js&lt;/strong&gt; — The titan of data visualization. But D3 is for &lt;em&gt;creating&lt;/em&gt; graphics from data, not for &lt;em&gt;viewing&lt;/em&gt; pre-made SVGs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;svg-pan-zoom&lt;/strong&gt; — A focused library for adding pan/zoom to SVGs. But it's just the engine — no UI, no search, no export.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Leaflet.js&lt;/strong&gt; — The standard for interactive maps. Overkill for a simple flowchart.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The gap was clear:&lt;/strong&gt; I needed a batteries-included solution — something that would just &lt;em&gt;work&lt;/em&gt; with a single &lt;code&gt;init()&lt;/code&gt; call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Start
&lt;/h2&gt;

&lt;h3&gt;
  
  
  CDN (Fastest)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- Panzoom (optional, for zoom/pan) --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://cdn.jsdelivr.net/npm/@panzoom/panzoom@4.5.1/dist/panzoom.min.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- DiagView --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://cdn.jsdelivr.net/npm/diagview@1.0.0/dist/diagview.umd.min.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- Your diagram --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"diagram"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;svg&amp;gt;&lt;/span&gt;&lt;span class="c"&gt;&amp;lt;!-- Your SVG content --&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/svg&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- Initialize --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;DiagView&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  NPM
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;diagview @panzoom/panzoom
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;DiagView&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;diagview&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;DiagView&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;floating&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;accentColor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#3b82f6&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Flexible Layouts
&lt;/h2&gt;

&lt;p&gt;DiagView supports three layout modes to fit your design:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa5kwsj319ogdgd5lyqww.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa5kwsj319ogdgd5lyqww.png" title="Header, Floating, and Off layout modes" alt="Layout Options" width="800" height="330"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layout&lt;/th&gt;
&lt;th&gt;Best For&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Header&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Classic top-bar controls, documentation sites&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Floating&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Clean HUD-style buttons on hover, minimal UIs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Off&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Invisible UI, the diagram itself is the trigger&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Under the Hood: Technical Decisions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Search Engine
&lt;/h3&gt;

&lt;p&gt;This was the feature I was most proud of. The search system:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pre-Caches Candidates&lt;/strong&gt; — On first open, queries all text elements and stores them in a WeakMap&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Uses Dirty Checking&lt;/strong&gt; — Before writing to the DOM, checks if values have changed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batches Updates&lt;/strong&gt; — All DOM mutations are wrapped in requestAnimationFrame&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result? Searching through diagrams with &lt;strong&gt;2,500+ nodes&lt;/strong&gt; is instant.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Export System
&lt;/h3&gt;

&lt;p&gt;The export module handles edge cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Robust Dimension Calculation&lt;/strong&gt; — Uses getBBox() to find actual content area&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-Origin Font Handling&lt;/strong&gt; — Inlines Google Fonts for consistent exports&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;High-DPI Scaling&lt;/strong&gt; — Up to 6x resolution for print-quality images&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Optional Panzoom Dependency
&lt;/h3&gt;

&lt;p&gt;I made panzoom an &lt;strong&gt;optional&lt;/strong&gt; peer dependency:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;With panzoom:&lt;/strong&gt; Full zoom, pan, touch gestures&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Without panzoom:&lt;/strong&gt; Fullscreen, search, and export still work&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This keeps DiagView usable even in constrained environments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bundle Size
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Raw Minified&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~70 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Gzipped (Transfer)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~19 KB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For context, that's smaller than a single hero image. And it includes all CSS, SVG icons, and the entire UI framework.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It Out
&lt;/h2&gt;

&lt;p&gt;I built this to scratch my own itch. If you write technical documentation for a living, I think you'll find it useful too.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🧪 &lt;strong&gt;Live Demo:&lt;/strong&gt; &lt;a href="https://khadirullah.github.io/diagview/" rel="noopener noreferrer"&gt;khadirullah.github.io/diagview&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;⭐ &lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/khadirullah/diagview" rel="noopener noreferrer"&gt;github.com/khadirullah/diagview&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📦 &lt;strong&gt;NPM:&lt;/strong&gt; &lt;a href="https://www.npmjs.com/package/diagview" rel="noopener noreferrer"&gt;npmjs.com/package/diagview&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Have feedback or found a bug? &lt;a href="https://github.com/khadirullah/diagview/issues" rel="noopener noreferrer"&gt;Open an issue on GitHub&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://khadirullah.com/blog/introducing-diagview/" rel="noopener noreferrer"&gt;khadirullah.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>opensource</category>
      <category>svg</category>
    </item>
  </channel>
</rss>
