Skip to content

Commit 9398d30

Browse files
committed
feat: network egress filtering via dnsmasq + ipset + iptables + capsh (Docker only)
Add OPEN_TERMINAL_ALLOWED_DOMAINS env var for domain-level network restriction. Uses dnsmasq as a local DNS resolver (NXDOMAIN for non-whitelisted domains), ipset for dynamic IP tracking (dnsmasq auto-populates resolved IPs), iptables to block external DNS and non-whitelisted IPs, and capsh to permanently drop CAP_NET_ADMIN. Supports wildcards (*.github.com), live DNS, fails closed. Set to empty string to block all; omit for full access. Gracefully skips on bare metal if iptables is unavailable. Bump version 0.11.11 → 0.11.12
1 parent 371a94c commit 9398d30

4 files changed

Lines changed: 89 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66

7+
## [0.11.12] - 2026-03-12
8+
9+
### Added
10+
11+
- 🔒 **Network egress filtering** (Docker only) — restrict which domains the container can access via the `OPEN_TERMINAL_ALLOWED_DOMAINS` env var. Supports wildcards (e.g. `*.github.com`). Set to empty string to block all outbound traffic; omit for full access. Skips gracefully on bare-metal installs.
12+
713
## [0.11.11] - 2026-03-11
814

915
### Fixed

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
2323
zip unzip tar gzip bzip2 xz-utils zstd p7zip-full \
2424
# System
2525
procps htop lsof strace sysstat \
26-
sudo tmux screen tini \
26+
sudo tmux screen tini iptables ipset dnsmasq \
2727
ca-certificates gnupg apt-transport-https \
2828
# Capabilities (needed for setcap on Python binary)
2929
libcap2-bin \

entrypoint.sh

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,85 @@ if [ -n "${OPEN_TERMINAL_PIP_PACKAGES:-}" ]; then
6767
pip install --no-cache-dir $OPEN_TERMINAL_PIP_PACKAGES
6868
fi
6969

70+
# -----------------------------------------------------------------------
71+
# Network egress filtering via DNS whitelist + iptables + capability drop
72+
#
73+
# OPEN_TERMINAL_ALLOWED_DOMAINS unset → full access
74+
# OPEN_TERMINAL_ALLOWED_DOMAINS="" → block ALL outbound
75+
# OPEN_TERMINAL_ALLOWED_DOMAINS="a,b" → DNS whitelist (dnsmasq)
76+
#
77+
# Restricted mode runs a local dnsmasq that only resolves whitelisted
78+
# domains. iptables blocks external DNS so the container must use the
79+
# local resolver. CAP_NET_ADMIN is permanently dropped via capsh.
80+
# -----------------------------------------------------------------------
81+
if [ "${OPEN_TERMINAL_ALLOWED_DOMAINS+set}" = "set" ]; then
82+
if ! command -v iptables &>/dev/null; then
83+
echo "WARNING: iptables not found — skipping egress firewall"
84+
exec open-terminal "$@"
85+
fi
86+
87+
# Flush any prior OUTPUT rules
88+
sudo iptables -F OUTPUT 2>/dev/null || true
89+
90+
# Always allow loopback + established connections
91+
sudo iptables -A OUTPUT -o lo -j ACCEPT
92+
sudo iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
93+
94+
if [ -z "$OPEN_TERMINAL_ALLOWED_DOMAINS" ]; then
95+
# ── Deny-all mode ──────────────────────────────────────────────
96+
echo "Egress: blocking ALL outbound traffic"
97+
sudo iptables -A OUTPUT -j DROP
98+
else
99+
# ── Restricted mode (DNS whitelist + ipset) ────────────────────
100+
echo "Egress: DNS whitelist — $OPEN_TERMINAL_ALLOWED_DOMAINS"
101+
102+
# Capture the current upstream nameserver before we override resolv.conf
103+
UPSTREAM_DNS=$(grep -m1 '^nameserver' /etc/resolv.conf | awk '{print $2}')
104+
UPSTREAM_DNS="${UPSTREAM_DNS:-8.8.8.8}"
105+
106+
# Create ipset for dynamically resolved IPs
107+
sudo ipset create allowed hash:ip -exist
108+
109+
# Generate dnsmasq config:
110+
# - NXDOMAIN for everything by default
111+
# - Forward allowed domains to upstream DNS
112+
# - Auto-add resolved IPs to the 'allowed' ipset
113+
sudo mkdir -p /etc/dnsmasq.d
114+
{
115+
echo "no-resolv"
116+
echo "no-hosts"
117+
echo "listen-address=127.0.0.1"
118+
echo "port=53"
119+
echo "address=/#/" # NXDOMAIN for everything by default
120+
121+
IFS=',' read -ra DOMAINS <<< "$OPEN_TERMINAL_ALLOWED_DOMAINS"
122+
for domain in "${DOMAINS[@]}"; do
123+
domain=$(echo "$domain" | xargs) # trim
124+
[ -z "$domain" ] && continue
125+
# Strip wildcard prefix — dnsmasq matches all subdomains natively
126+
domain="${domain#\*.}"
127+
echo "server=/${domain}/${UPSTREAM_DNS}"
128+
echo "ipset=/${domain}/allowed"
129+
echo "${domain} (+ subdomains)" >&2
130+
done
131+
} | sudo tee /etc/dnsmasq.d/egress.conf > /dev/null
132+
133+
# Start dnsmasq as a background daemon
134+
sudo dnsmasq --conf-file=/etc/dnsmasq.d/egress.conf
135+
echo "dnsmasq started (upstream: ${UPSTREAM_DNS})"
136+
137+
# Point the container at our local resolver
138+
echo "nameserver 127.0.0.1" | sudo tee /etc/resolv.conf > /dev/null
139+
140+
# iptables: allow ONLY resolved IPs (via ipset) + block everything else
141+
sudo iptables -A OUTPUT -p udp --dport 53 -j DROP # block external DNS
142+
sudo iptables -A OUTPUT -p tcp --dport 53 -j DROP # block external DNS
143+
sudo iptables -A OUTPUT -m set --match-set allowed dst -j ACCEPT # allow resolved IPs
144+
sudo iptables -A OUTPUT -j DROP # drop everything else
145+
fi
146+
147+
echo "Egress firewall active — dropping CAP_NET_ADMIN permanently"
148+
exec capsh --drop=cap_net_admin -- -c "exec open-terminal $*"
149+
fi
150+
70151
exec open-terminal "$@"

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "open-terminal"
3-
version = "0.11.11"
3+
version = "0.11.12"
44
description = "A remote terminal API."
55
readme = "README.md"
66
authors = [

0 commit comments

Comments
 (0)