Files
Aryan Singh 3b5f3b0a16
Checks / python (3.12) (push) Canceled after 0s
Checks / python (3.14) (push) Canceled after 0s
Checks / python (3.9) (push) Canceled after 0s
Checks / haproxy (push) Canceled after 0s
Initial commit
2026-09-15 18:52:00 -05:00

163 lines
7.3 KiB
Bash

#!/usr/bin/env bash
# Dedicated OCI Ubuntu 24.04 only. Run on Oracle, not on your home firewall.
# Requires existing iptables, nftables, netfilter-persistent, Python and systemd.
set -Eeuo pipefail
umask 077
die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
[[ $EUID -eq 0 ]] || die 'Run as root, preserving SSH_CONNECTION when using sudo.'
[[ $# -ge 1 && $# -le 2 ]] || die 'Usage: install-firewall.sh ADMIN_IPV4_CIDR [--console]'
[[ ${2:-} == '' || ${2:-} == --console ]] || die 'Only --console is supported as the second argument.'
for executable in python3 iptables ip6tables iptables-save ip6tables-save iptables-restore ip6tables-restore nft systemctl systemd-run netfilter-persistent; do
command -v "$executable" >/dev/null || die "Required command missing: $executable"
done
[[ -r /etc/os-release ]] || die 'Cannot identify the operating system.'
. /etc/os-release
[[ ${ID:-} == ubuntu && ${VERSION_ID:-} == 24.04 ]] || die 'This helper requires Ubuntu 24.04.'
[[ $(iptables --version) == *nf_tables* && $(ip6tables --version) == *nf_tables* ]] || die 'Expected the stock iptables-nft backend for both families.'
[[ -n ${SSH_CONNECTION:-} || ${2:-} == --console ]] || die 'SSH_CONNECTION is absent. Preserve it through sudo, or use --console only from the OCI console.'
admin_cidr=$(python3 - "$1" "${SSH_CONNECTION:-}" <<'PY'
import ipaddress, sys
network = ipaddress.ip_network(sys.argv[1], strict=False)
if network.version != 4:
raise SystemExit('The administrator CIDR must be IPv4.')
if network.prefixlen == 0:
raise SystemExit('Refusing unrestricted public SSH; use your administrator /32 or trusted network.')
if sys.argv[2]:
fields = sys.argv[2].split()
if len(fields) != 4:
raise SystemExit('Malformed SSH_CONNECTION.')
if fields[3] != '22':
raise SystemExit('This helper permits SSH port 22 only; current SSH uses another port.')
source = ipaddress.ip_address(fields[0])
if source.version != 4 or source not in network:
raise SystemExit('Current SSH source is outside the administrator IPv4 CIDR.')
print(network)
PY
)
for manager in ufw firewalld nftables docker; do
if systemctl is-active --quiet "$manager.service"; then
die "Competing service is active: $manager. Use a fresh dedicated VM."
fi
done
command -v docker >/dev/null && die 'Docker is installed; this helper is for a dedicated VM without Docker.'
rules_v4=$(iptables-save 2>&1)
rules_v6=$(ip6tables-save 2>&1)
rule_inventory="$rules_v4$rules_v6"
[[ $rule_inventory != *Warning* && $rule_inventory != *incompatible* ]] || die 'iptables reports incompatible/native or legacy rules; inspect manually.'
[[ $rule_inventory != *EDGE_INPUT* && $rule_inventory != *DOCKER* && $rule_inventory != *ufw-* ]] || die 'Existing edge, Docker or UFW chains found; refusing to overwrite them.'
nft -j list tables | python3 -c '
import json, sys
allowed = {"filter", "nat", "mangle", "raw", "security"}
for item in json.load(sys.stdin)["nftables"]:
table = item.get("table")
if table and (table["family"] not in {"ip", "ip6"} or table["name"] not in allowed):
raise SystemExit("Unexpected native nftables table; inspect manually: " + str(table))
'
backup_dir=$(mktemp -d /root/oracle-edge-backup.XXXXXXXX)
iptables-save >"$backup_dir/live.v4"
ip6tables-save >"$backup_dir/live.v6"
for family in v4 v6; do
if [[ -e /etc/iptables/rules.$family ]]; then
cp /etc/iptables/rules."$family" "$backup_dir/persistent.$family"
else
touch "$backup_dir/persistent.$family.absent"
fi
done
chmod 600 "$backup_dir"/*
unit_name="oracle-edge-rollback-$(date +%s)-$$"
printf 'BACKUP_DIR=%q\n' "$backup_dir" >"$backup_dir/rollback.env"
cat >"$backup_dir/rollback.sh" <<'ROLLBACK'
#!/usr/bin/env bash
set -uo pipefail
umask 077
. "$(dirname "$0")/rollback.env"
result=0
# Restore both complete pre-change rulesets, retaining OCI OUTPUT protections.
iptables-restore --wait 10 <"$BACKUP_DIR/live.v4" || result=1
ip6tables-restore --wait 10 <"$BACKUP_DIR/live.v6" || result=1
for family in v4 v6; do
if [[ -f $BACKUP_DIR/persistent.$family.absent ]]; then
rm -f /etc/iptables/rules."$family" || result=1
else
mkdir -p /etc/iptables || result=1
install -m 600 "$BACKUP_DIR/persistent.$family" /etc/iptables/rules."$family" || result=1
fi
done
exit "$result"
ROLLBACK
chmod 700 "$backup_dir/rollback.sh"
rollback_on_error() {
local result=$1
trap - ERR INT TERM
printf 'Installation failed; restoring backups from %s\n' "$backup_dir" >&2
if bash "$backup_dir/rollback.sh"; then
systemctl stop "$unit_name.timer" 2>/dev/null || true
else
printf 'Rollback reported an error; use the OCI console. Timer remains armed.\n' >&2
fi
exit "$result"
}
trap 'rollback_on_error $?' ERR
trap 'rollback_on_error 130' INT
trap 'rollback_on_error 143' TERM
# Arm recovery before making any rule changes. Nothing is persisted yet.
systemd-run --unit="$unit_name" --on-active=180s --timer-property=AccuracySec=1s \
--property=Type=oneshot /bin/bash "$backup_dir/rollback.sh"
systemctl is-active --quiet "$unit_name.timer"
# Build unattached chains. Existing INPUT paths remain effective during staging.
iptables --wait 10 -N EDGE_INPUT
ip6tables --wait 10 -N EDGE_INPUT6
for family in v4 v6; do
if [[ $family == v4 ]]; then
firewall=iptables
chain=EDGE_INPUT
else
firewall=ip6tables
chain=EDGE_INPUT6
fi
"$firewall" --wait 10 -A "$chain" -i lo -j ACCEPT
"$firewall" --wait 10 -A "$chain" -m conntrack --ctstate INVALID -j DROP
"$firewall" --wait 10 -A "$chain" -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
if [[ $family == v4 ]]; then
"$firewall" --wait 10 -A "$chain" -p icmp -j ACCEPT
"$firewall" --wait 10 -A "$chain" -p udp --sport 67 --dport 68 -j ACCEPT
"$firewall" --wait 10 -A "$chain" -s "$admin_cidr" -p tcp --dport 22 -m conntrack --ctstate NEW -j ACCEPT
"$firewall" --wait 10 -A "$chain" -p tcp --dport 443 -m conntrack --ctstate NEW -j ACCEPT
else
"$firewall" --wait 10 -A "$chain" -p ipv6-icmp -j ACCEPT
"$firewall" --wait 10 -A "$chain" -p udp --sport 547 --dport 546 -j ACCEPT
fi
"$firewall" --wait 10 -A "$chain" -j DROP
done
# Within each family the completed chain becomes live with one head insertion.
iptables --wait 10 -I INPUT 1 -j EDGE_INPUT
ip6tables --wait 10 -I INPUT 1 -j EDGE_INPUT6
trap - ERR INT TERM
cat <<INSTRUCTIONS
Firewall active temporarily. Automatic rollback is armed for 180 seconds.
Backup and manual rollback: sudo bash '$backup_dir/rollback.sh'
Before that deadline, keep this session open and test from a NEW terminal:
ssh YOUR_ORACLE_USER@ORACLE_PUBLIC_IP
curl --fail --resolve YOUR_HOST:443:ORACLE_PUBLIC_IP https://YOUR_HOST/
The relay must already be running for the curl check to pass.
Also confirm unauthorized sources cannot open SSH; IPv6 has no new TCP ingress.
Only after the tests pass, run ON ORACLE:
sudo systemctl stop '$unit_name.timer'
sudo systemctl is-active '$unit_name.service'
The service must be inactive, not activating/active. If rollback already ran,
or the timer expired, DO NOT save: inspect the live rules and rerun the helper.
sudo iptables -C INPUT -j EDGE_INPUT
sudo ip6tables -C INPUT -j EDGE_INPUT6
sudo netfilter-persistent save
No rules have been persisted and the rollback timer has not been cancelled.
INSTRUCTIONS