Files
open-terminal/entrypoint-slim.sh
T
2026-10-24 04:00:02 +09:00

217 lines
8.1 KiB
Bash
Executable File

#!/bin/sh
set -e
# ============================================================================
# Open Terminal — Hardened Entrypoint (Slim & Alpine)
# ============================================================================
#
# This entrypoint is shared by both the slim (Debian) and Alpine images.
# It handles:
# 1. Docker-secrets support (_FILE env vars)
# 2. Home directory permissions & dotfile seeding
# 3. Network egress filtering (iptables + dnsmasq whitelist)
#
# What it does NOT do (by design):
# ✘ No runtime apt/pip package installation
# ✘ No Docker socket detection
# ✘ No sudo — privilege drop uses gosu (Debian) or su-exec (Alpine)
#
# ============================================================================
# ── Helper: Docker-secrets _FILE resolution ────────────────────────────────
#
# Follows the convention from the official PostgreSQL image.
# If OPEN_TERMINAL_API_KEY_FILE is set, its contents become the key.
#
file_env() {
local var="$1"
local fileVar="${var}_FILE"
local def="${2:-}"
local val="$def"
eval local currentVal="\${$var:-}"
eval local fileVal="\${$fileVar:-}"
eval local varIsSet="\${$var+set}"
eval local fileIsSet="\${$fileVar+set}"
if [ "$varIsSet" = "set" ] && [ "$fileIsSet" = "set" ]; then
printf >&2 'error: both %s and %s are set (but are exclusive)\n' "$var" "$fileVar"
exit 1
fi
if [ -n "$currentVal" ]; then
val="$currentVal"
elif [ -n "$fileVal" ]; then
val="$(cat "$fileVal")"
fi
export "$var"="$val"
unset "$fileVar"
}
file_env 'OPEN_TERMINAL_API_KEY'
# ── Helper: drop privileges to "user" ─────────────────────────────────────
#
# Detects gosu (Debian) vs su-exec (Alpine) automatically.
# Both do the same thing: exec a command as a different user.
#
drop_to_user() {
if command -v gosu >/dev/null 2>&1; then
exec gosu user "$@"
elif command -v su-exec >/dev/null 2>&1; then
exec su-exec user "$@"
else
# Fallback: already running as the right user
exec "$@"
fi
}
# ── Home directory setup ──────────────────────────────────────────────────
#
# When /home/user is bind-mounted empty, Docker doesn't populate it with
# the image contents. We seed essential dotfiles so bash works properly.
#
fix_home() {
local home="/home/user"
# Fix ownership if the mount changed it (works on both GNU and BusyBox stat)
local owner_uid
owner_uid=$(stat -c '%u' "$home" 2>/dev/null) || owner_uid=$(stat -f '%u' "$home" 2>/dev/null) || owner_uid="1000"
if [ "$owner_uid" != "1000" ]; then
chown -R user:user "$home" 2>/dev/null || true
fi
# Seed bashrc / profile from skeleton if missing
if [ ! -f "$home/.bashrc" ]; then
if [ -f /etc/skel/.bashrc ]; then
cp /etc/skel/.bashrc "$home/.bashrc" 2>/dev/null || true
fi
fi
if [ ! -f "$home/.profile" ]; then
if [ -f /etc/skel/.profile ]; then
cp /etc/skel/.profile "$home/.profile" 2>/dev/null || true
fi
fi
mkdir -p "$home/.local/bin"
chown -R user:user "$home" 2>/dev/null || true
}
# ── Network egress filtering ──────────────────────────────────────────────
#
# OPEN_TERMINAL_ALLOWED_DOMAINS unset → full internet access
# OPEN_TERMINAL_ALLOWED_DOMAINS="" → block ALL outbound traffic
# OPEN_TERMINAL_ALLOWED_DOMAINS="a,b" → DNS whitelist via dnsmasq
#
# When restricted, a local dnsmasq resolves only whitelisted domains.
# iptables blocks all other outbound traffic. CAP_NET_ADMIN is then
# permanently dropped so the rules can't be undone from inside.
#
setup_egress_firewall() {
if ! command -v iptables >/dev/null 2>&1; then
echo "ERROR: iptables is required for egress filtering" >&2
exit 1
fi
# Flush any prior OUTPUT rules
iptables -F OUTPUT 2>/dev/null || true
# Capture upstream DNS before replacing resolv.conf, including in deny-all mode.
UPSTREAM_DNS=$(grep -m1 '^nameserver' /etc/resolv.conf | awk '{print $2}')
UPSTREAM_DNS="${UPSTREAM_DNS:-8.8.8.8}"
if [ -z "$OPEN_TERMINAL_ALLOWED_DOMAINS" ]; then
# ── Deny-all mode ──────────────────────────────────────────────
echo "Egress: blocking ALL outbound traffic"
else
# ── Restricted mode (DNS whitelist + ipset) ────────────────────
echo "Egress: DNS whitelist — $OPEN_TERMINAL_ALLOWED_DOMAINS"
# Create ipset for dynamically resolved IPs
ipset create allowed hash:ip -exist
# Generate dnsmasq config:
# - NXDOMAIN for everything by default
# - Forward allowed domains to upstream DNS
# - Auto-add resolved IPs to the 'allowed' ipset
mkdir -p /etc/dnsmasq.d
{
echo "user=dnsmasq"
echo "no-resolv"
echo "no-hosts"
echo "listen-address=127.0.0.1"
echo "port=53"
echo "address=/#/" # NXDOMAIN for everything by default
# Process each comma-separated domain
echo "$OPEN_TERMINAL_ALLOWED_DOMAINS" | tr ',' '\n' | while read -r domain; do
domain=$(echo "$domain" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
[ -z "$domain" ] && continue
# Strip wildcard prefix — dnsmasq matches all subdomains natively
domain="${domain#\*.}"
echo "server=/${domain}/${UPSTREAM_DNS}"
echo "ipset=/${domain}/allowed"
echo " ✓ ${domain} (+ subdomains)" >&2
done
} > /etc/dnsmasq.d/egress.conf
# Start dnsmasq as a background daemon
dnsmasq --conf-file=/etc/dnsmasq.d/egress.conf
echo "dnsmasq started (upstream: ${UPSTREAM_DNS})"
# Point the container at our local resolver
echo "nameserver 127.0.0.1" > /etc/resolv.conf
# Match the original destination: Docker rewrites its embedded DNS port.
# Check DNS before allowing loopback or established connections.
for protocol in udp tcp; do
iptables -A OUTPUT -p "$protocol" -m conntrack --ctdir ORIGINAL \
--ctorigdst "$UPSTREAM_DNS" --ctorigdstport 53 \
-m owner --uid-owner dnsmasq -j ACCEPT
iptables -A OUTPUT -p "$protocol" -m conntrack --ctdir ORIGINAL \
--ctorigdst 127.0.0.1 --ctorigdstport 53 -j ACCEPT
done
fi
# Also block Docker's loopback DNS in deny-all mode.
for protocol in udp tcp; do
iptables -A OUTPUT -p "$protocol" -m conntrack --ctdir ORIGINAL \
--ctorigdstport 53 -j DROP
done
# Prevent direct access to Docker DNS's translated high ports as well.
iptables -A OUTPUT -o lo -d "$UPSTREAM_DNS" -j DROP
iptables -A OUTPUT -o lo -j ACCEPT
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
if [ -n "$OPEN_TERMINAL_ALLOWED_DOMAINS" ]; then
iptables -A OUTPUT -m set --match-set allowed dst -j ACCEPT
fi
iptables -A OUTPUT -j DROP
echo "Egress firewall active — dropping CAP_NET_ADMIN permanently"
}
# ============================================================================
# Main
# ============================================================================
fix_home
# Export env vars for the app user (since we're running as root, these
# won't be inherited automatically).
export HOME="/home/user"
export PATH="/home/user/.local/bin:${PATH}"
# Call setup outside a conditional so failures stop startup under set -e.
if [ "${OPEN_TERMINAL_ALLOWED_DOMAINS+set}" = "set" ]; then
setup_egress_firewall
exec capsh --drop=cap_net_admin --user=user -- -c 'exec open-terminal "$@"' -- "$@"
else
drop_to_user open-terminal "$@"
fi