LAN-Only Internet Kill Switch
When testing system deployments meant for air-gapped networks, you
need a way to simulate having no internet access while keeping LAN
connectivity intact. Rather than physically unplugging cables or
reconfiguring your router, this bash script gives you a quick toggle
to block all outbound internet traffic using iptables/ip6tables,
while preserving local network access.
This is useful for verifying that your deployment scripts, container images, and service configurations actually work without reaching out to the internet — catching missing dependencies, hardcoded external URLs, or package manager calls that would fail in a real air-gapped environment.
The script blocks all outbound traffic except:
- LAN traffic — RFC 1918 IPv4 ranges (
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16) and IPv6 link-local / ULA ranges continue to work normally. - DNS — UDP and TCP port 53 to any destination, so name resolution keeps working.
- Explicit exceptions — You can allowlist specific hostnames or
IP addresses by editing the
EXCEPTIONSarray at the top of the script. You can optionally restrict exceptions to specific TCP/UDP ports.
It works with both iptables-legacy and iptables-nft (compat
layer), and handles both IPv4 and IPv6.
How it works
The script creates a custom iptables chain (LANONLY_OUT /
LANONLY6_OUT) and inserts a jump to it at the top of the OUTPUT
chain. Inside that chain, it accepts DNS, LAN destinations, and any
configured exceptions, then drops everything else. Turning it off
simply removes the chain and its jump rule, restoring normal
connectivity.
Installation
Download the script and make it executable:
wget https://raw.githubusercontent.com/EnigmaCurry/blog.rymcg.tech/master/src/network/internet-deny.sh
chmod +x internet-deny.sh
Configuration
To allowlist specific hosts, edit the EXCEPTIONS array near the top
of the script:
EXCEPTIONS=(
"example.com"
"1.1.1.1"
"2606:4700:4700::1111"
)
Hostnames are resolved at enable time. To restrict exception hosts to specific ports:
EXCEPTION_TCP_PORTS=(
443 22
)
EXCEPTION_UDP_PORTS=(
123
)
If the port arrays are left empty, all ports are allowed to exception destinations.
Usage
The script requires root privileges and will automatically invoke
sudo if needed:
## Enable LAN-only mode (block internet):
./internet-deny.sh on
## Disable LAN-only mode (restore internet):
./internet-deny.sh off
## Check current status:
./internet-deny.sh status
Notes
- The rules are not persistent across reboots — if you reboot,
internet access is restored. Run
./internet-deny.sh onagain after boot if you want to re-enable it. - DNS is always allowed so that hostname resolution works. If you need to block DNS too, you’ll need to modify the script.
- Exception hostnames are resolved to IP addresses once at enable
time. If the DNS records change, you’ll need to cycle
off/onto pick up the new addresses.
The script
#!/usr/bin/env bash
set -euo pipefail
# ============================================================
# LAN-only "internet kill switch" using iptables/ip6tables
#
# - Keeps LAN (RFC1918 IPv4) working
# - Allows DNS to anywhere (UDP/TCP 53)
# - Allows additional exception destinations (hostname/IP)
# - Blocks everything else outbound
#
# Works on: iptables-legacy and iptables-nft (compat).
# ============================================================
# ----- User config -----
EXCEPTIONS=(
# "example.com"
# "1.1.1.1"
# "2606:4700:4700::1111"
)
# Leave these empty to allow ALL ports/protocols to exception IPs.
# If you set them, only those ports will be allowed to exception IPs (DNS always allowed).
EXCEPTION_TCP_PORTS=(
# 443 22
)
EXCEPTION_UDP_PORTS=(
# 123
)
# ----------------------
CHAIN_V4="LANONLY_OUT"
CHAIN_V6="LANONLY6_OUT"
# RFC1918 + loopback
LAN_V4_CIDRS=( "127.0.0.0/8" "10.0.0.0/8" "172.16.0.0/12" "192.168.0.0/16" )
# IPv6 LAN-ish: loopback, link-local, ULA (if you use it)
LAN_V6_CIDRS=( "::1/128" "fe80::/10" "fc00::/7" )
have() { command -v "$1" >/dev/null 2>&1; }
usage() {
cat <<'EOF'
Usage: lanonly.sh {on|off|status}
on Enable LAN-only mode
off Disable LAN-only mode
status Show whether LAN-only mode is active
EOF
}
need_root() {
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
if have sudo; then
exec sudo -E "$0" "$@"
fi
echo "ERROR: must run as root (sudo not found)." >&2
exit 1
fi
}
# ---- resolution helpers ----
is_ipv4() { [[ "$1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; }
is_ipv6() { [[ "$1" == *:* ]]; }
resolve_one() {
# prints resolved IPs (v4/v6) one per line
local name="$1"
# literal IP
if is_ipv4 "$name" || is_ipv6 "$name"; then
printf '%s\n' "$name"
return 0
fi
# getent (preferred)
if have getent; then
getent ahosts "$name" 2>/dev/null | awk '{print $1}' | sort -u
return 0
fi
# dig fallback
if have dig; then
{ dig +short A "$name"; dig +short AAAA "$name"; } 2>/dev/null | awk 'NF' | sort -u
return 0
fi
# host fallback
if have host; then
host -t A "$name" 2>/dev/null | awk '/has address/ {print $4}'
host -t AAAA "$name" 2>/dev/null | awk '/has IPv6 address/ {print $5}'
return 0
fi
echo "WARNING: no resolver tool (getent/dig/host) to resolve: $name" >&2
return 0
}
resolve_exceptions() {
EXC_V4=()
EXC_V6=()
local item ip
local -A seen4=()
local -A seen6=()
for item in "${EXCEPTIONS[@]:-}"; do
[[ -z "$item" ]] && continue
while IFS= read -r ip; do
[[ -z "$ip" ]] && continue
if is_ipv4 "$ip"; then
[[ -n "${seen4[$ip]:-}" ]] && continue
seen4["$ip"]=1
EXC_V4+=("$ip")
elif is_ipv6 "$ip"; then
[[ -n "${seen6[$ip]:-}" ]] && continue
seen6["$ip"]=1
EXC_V6+=("$ip")
fi
done < <(resolve_one "$item" || true)
done
}
# ---- iptables chain management ----
v4_active() {
iptables -C OUTPUT -j "$CHAIN_V4" >/dev/null 2>&1
}
v6_active() {
ip6tables -C OUTPUT -j "$CHAIN_V6" >/dev/null 2>&1
}
ensure_chain_v4() {
iptables -N "$CHAIN_V4" 2>/dev/null || true
iptables -F "$CHAIN_V4"
# ensure jump from OUTPUT (at top)
iptables -C OUTPUT -j "$CHAIN_V4" 2>/dev/null || iptables -I OUTPUT 1 -j "$CHAIN_V4"
}
ensure_chain_v6() {
ip6tables -N "$CHAIN_V6" 2>/dev/null || true
ip6tables -F "$CHAIN_V6"
ip6tables -C OUTPUT -j "$CHAIN_V6" 2>/dev/null || ip6tables -I OUTPUT 1 -j "$CHAIN_V6"
}
remove_chain_v4() {
iptables -D OUTPUT -j "$CHAIN_V4" 2>/dev/null || true
iptables -F "$CHAIN_V4" 2>/dev/null || true
iptables -X "$CHAIN_V4" 2>/dev/null || true
}
remove_chain_v6() {
ip6tables -D OUTPUT -j "$CHAIN_V6" 2>/dev/null || true
ip6tables -F "$CHAIN_V6" 2>/dev/null || true
ip6tables -X "$CHAIN_V6" 2>/dev/null || true
}
# ---- rule population ----
populate_v4_rules() {
# Always allow DNS anywhere
iptables -A "$CHAIN_V4" -p udp --dport 53 -j ACCEPT
iptables -A "$CHAIN_V4" -p tcp --dport 53 -j ACCEPT
# Allow LAN ranges
local cidr
for cidr in "${LAN_V4_CIDRS[@]}"; do
iptables -A "$CHAIN_V4" -d "$cidr" -j ACCEPT
done
# Allow exceptions (resolved at enable time)
local ip p
for ip in "${EXC_V4[@]:-}"; do
[[ -z "$ip" ]] && continue
if [[ "${#EXCEPTION_TCP_PORTS[@]}" -eq 0 && "${#EXCEPTION_UDP_PORTS[@]}" -eq 0 ]]; then
iptables -A "$CHAIN_V4" -d "$ip" -j ACCEPT
else
for p in "${EXCEPTION_TCP_PORTS[@]:-}"; do
[[ -z "$p" ]] && continue
iptables -A "$CHAIN_V4" -p tcp -d "$ip" --dport "$p" -j ACCEPT
done
for p in "${EXCEPTION_UDP_PORTS[@]:-}"; do
[[ -z "$p" ]] && continue
iptables -A "$CHAIN_V4" -p udp -d "$ip" --dport "$p" -j ACCEPT
done
fi
done
# Drop everything else
iptables -A "$CHAIN_V4" -j DROP
}
populate_v6_rules() {
# Always allow DNS anywhere
ip6tables -A "$CHAIN_V6" -p udp --dport 53 -j ACCEPT
ip6tables -A "$CHAIN_V6" -p tcp --dport 53 -j ACCEPT
# Allow LAN-ish IPv6
local cidr
for cidr in "${LAN_V6_CIDRS[@]}"; do
ip6tables -A "$CHAIN_V6" -d "$cidr" -j ACCEPT
done
# Allow exceptions
local ip p
for ip in "${EXC_V6[@]:-}"; do
[[ -z "$ip" ]] && continue
if [[ "${#EXCEPTION_TCP_PORTS[@]}" -eq 0 && "${#EXCEPTION_UDP_PORTS[@]}" -eq 0 ]]; then
ip6tables -A "$CHAIN_V6" -d "$ip" -j ACCEPT
else
for p in "${EXCEPTION_TCP_PORTS[@]:-}"; do
[[ -z "$p" ]] && continue
ip6tables -A "$CHAIN_V6" -p tcp -d "$ip" --dport "$p" -j ACCEPT
done
for p in "${EXCEPTION_UDP_PORTS[@]:-}"; do
[[ -z "$p" ]] && continue
ip6tables -A "$CHAIN_V6" -p udp -d "$ip" --dport "$p" -j ACCEPT
done
fi
done
# Drop everything else
ip6tables -A "$CHAIN_V6" -j DROP
}
do_on() {
need_root "$@"
have iptables || { echo "ERROR: iptables not found." >&2; exit 1; }
resolve_exceptions
ensure_chain_v4
populate_v4_rules
if have ip6tables; then
ensure_chain_v6
populate_v6_rules
fi
echo "LAN-only enabled."
if [[ "${#EXC_V4[@]}" -gt 0 || "${#EXC_V6[@]}" -gt 0 ]]; then
echo "Resolved exceptions:"
[[ "${#EXC_V4[@]}" -gt 0 ]] && printf ' IPv4: %s\n' "${EXC_V4[@]}"
[[ "${#EXC_V6[@]}" -gt 0 ]] && printf ' IPv6: %s\n' "${EXC_V6[@]}"
fi
}
do_off() {
need_root "$@"
have iptables || { echo "ERROR: iptables not found." >&2; exit 1; }
remove_chain_v4
if have ip6tables; then
remove_chain_v6
fi
echo "LAN-only disabled."
}
do_status() {
need_root "$@"
have iptables || { echo "iptables not found." >&2; exit 1; }
if v4_active; then
echo "IPv4 LAN-only: ON"
else
echo "IPv4 LAN-only: OFF"
fi
if have ip6tables; then
if v6_active; then
echo "IPv6 LAN-only: ON"
else
echo "IPv6 LAN-only: OFF"
fi
else
echo "IPv6 LAN-only: OFF (ip6tables missing)"
fi
}
main() {
[[ $# -eq 1 ]] || { usage; exit 1; }
case "$1" in
on) do_on "$@" ;;
off) do_off "$@" ;;
status) do_status "$@" ;;
-h|--help|help) usage ;;
*) usage; exit 1 ;;
esac
}
main "$@"
You can discuss this blog on Matrix (Element): #blog-rymcg-tech:enigmacurry.com
This blog is copyright EnigmaCurry and dual-licensed CC-BY-SA and MIT. The source is on github: enigmacurry/blog.rymcg.tech and PRs are welcome. ❤️