#!/bin/bash
# dfw - open and close ports in the Das OS server firewall (nftables).
#
# /etc/nftables.conf defines the "das" table with the sets tcp_ports and
# udp_ports; dfw keeps their elements in /etc/nftables.d/dfw.nft, which that
# file includes, and reloads only the "das" table. SSH is always open.
set -euo pipefail

# shellcheck source=helpers.sh
. "${DAS_HELPERS:-/usr/lib/das/helpers.sh}"

conf=/etc/nftables.conf
ports_file=/etc/nftables.d/dfw.nft

usage() {
  if [[ -n $uk ]]; then
    cat <<'EOF'
Використання: dfw КОМАНДА [ПОРТ...]

  status                  стан брандмауера й відкриті порти (типова команда)
  allow ПОРТ...           відкрити порти для вхідних з'єднань
  deny ПОРТ...            закрити порти, відкриті через allow
  reload                  перечитати /etc/nftables.conf після редагування

ПОРТ: 80, 80/tcp, 53/udp, 8000-8010/tcp або назва служби (http, https).
Без /udp мається на увазі tcp. SSH відкритий завжди.

Порти, які публікують контейнери Docker (-p 8080:80), відкриває сам Docker,
dfw на них не впливає.
EOF
  else
    cat <<'EOF'
Usage: dfw COMMAND [PORT...]

  status                  firewall state and open ports (the default)
  allow PORT...           open ports for incoming connections
  deny PORT...            close ports opened with allow
  reload                  load /etc/nftables.conf again after editing it

PORT: 80, 80/tcp, 53/udp, 8000-8010/tcp or a service name (http, https).
Without /udp it means tcp. SSH is always open.

Ports that Docker containers publish (-p 8080:80) are opened by Docker
itself, dfw does not affect them.
EOF
  fi
}

check_config() {
  grep -qs 'set tcp_ports' "$conf" ||
    die "$conf не з Das OS Server (немає набору tcp_ports); dfw працює лише з ним" \
      "$conf is not the Das OS Server one (no tcp_ports set); dfw only works with that"
}

# Opened ports of one protocol from the ports file, one per line
saved_ports() {
  [[ -r $ports_file ]] || return 0
  sed -n "s/^add element inet das $1_ports { \(.*\) }$/\1/p" "$ports_file" | tr -d ' ' | tr ',' '\n'
}

# parse_port SPEC: prints "PORT PROTO" with PORT a number or a FIRST-LAST range
parse_port() {
  local spec=$1 port proto=tcp first last
  if [[ $spec == */* ]]; then
    port=${spec%/*}
    proto=${spec##*/}
  else
    port=$spec
  fi
  [[ $proto == tcp || $proto == udp ]] || die "'$spec': протокол tcp або udp" "'$spec': the protocol is tcp or udp"
  if [[ $port =~ ^[a-z][a-z0-9-]*$ ]]; then
    # A service name from /etc/services
    first=$(getent services "$port/$proto" | awk '{ print $2 }')
    [[ -n $first ]] || die "'$spec': невідома служба $port/$proto" "'$spec': unknown service $port/$proto"
    port=${first%/*}
  fi
  if [[ $port =~ ^([0-9]+)-([0-9]+)$ ]]; then
    first=${BASH_REMATCH[1]}
    last=${BASH_REMATCH[2]}
  elif [[ $port =~ ^[0-9]+$ ]]; then
    first=$port
    last=$port
  else
    die "'$spec': порт — число, діапазон 8000-8010 або назва служби" \
      "'$spec': a port is a number, a range like 8000-8010 or a service name"
  fi
  ((first >= 1 && last <= 65535 && first <= last)) ||
    die "'$spec': порти від 1 до 65535" "'$spec': ports are 1 to 65535"
  if ((first == last)); then
    echo "$first $proto"
  else
    echo "$first-$last $proto"
  fi
}

firewall_on() {
  [[ -e /etc/runit/runsvdir/default/nftables ]]
}

# save TCP_PORTS UDP_PORTS (newline-separated): writes the ports file, checks
# the whole configuration and loads it, restoring the old file on an error
save() {
  local tcp udp backup=""
  # sort -n -u would take 80 and 80-90 for duplicates
  tcp=$(sort -u <<<"$1" | sort -n | grep . | paste -sd, - | sed 's/,/, /g' || true)
  udp=$(sort -u <<<"$2" | sort -n | grep . | paste -sd, - | sed 's/,/, /g' || true)
  mkdir -p "${ports_file%/*}"
  if [[ -e $ports_file ]]; then
    backup=$(mktemp)
    cp "$ports_file" "$backup"
  fi
  {
    echo "# Ports opened with dfw; change them with dfw allow/deny"
    [[ -z $tcp ]] || echo "add element inet das tcp_ports { $tcp }"
    [[ -z $udp ]] || echo "add element inet das udp_ports { $udp }"
  } >"$ports_file"
  if ! nft -c -f "$conf"; then
    if [[ -n $backup ]]; then mv "$backup" "$ports_file"; else rm -f "$ports_file"; fi
    die "nftables не прийняв правила, нічого не змінено" "nftables rejected the rules, nothing changed"
  fi
  rm -f "$backup"
  if firewall_on; then
    nft -f "$conf"
  else
    warn "брандмауер вимкнений, зміни збережено; увімкнути: dsv enable nftables" \
      "the firewall is off, changes are saved; to turn it on: dsv enable nftables"
  fi
}

cmd_allow() {
  local spec port proto tcp udp
  tcp=$(saved_ports tcp)
  udp=$(saved_ports udp)
  for spec in "$@"; do
    read -r port proto < <(parse_port "$spec")
    if [[ $port == 22 && $proto == tcp ]]; then
      say "22/tcp (SSH) відкритий завжди" "22/tcp (SSH) is always open"
      continue
    fi
    if grep -qx "$port" <<<"$(if [[ $proto == tcp ]]; then echo "$tcp"; else echo "$udp"; fi)"; then
      say "$port/$proto: уже відкритий" "$port/$proto: already open"
      continue
    fi
    if [[ $proto == tcp ]]; then tcp+=$'\n'$port; else udp+=$'\n'$port; fi
    say "$port/$proto: відкрито" "$port/$proto: open"
  done
  save "$tcp" "$udp"
}

cmd_deny() {
  local spec port proto tcp udp list
  tcp=$(saved_ports tcp)
  udp=$(saved_ports udp)
  for spec in "$@"; do
    read -r port proto < <(parse_port "$spec")
    if [[ $port == 22 && $proto == tcp ]]; then
      die "SSH не закривається через dfw, щоб не втратити доступ до сервера; правило — у $conf" \
        "SSH is not closed with dfw, so as not to lose access to the server; the rule is in $conf"
    fi
    if [[ $proto == tcp ]]; then list=$tcp; else list=$udp; fi
    if ! grep -qx "$port" <<<"$list"; then
      say "$port/$proto: не був відкритий через dfw" "$port/$proto: was not opened with dfw"
      continue
    fi
    list=$(grep -vx "$port" <<<"$list" || true)
    if [[ $proto == tcp ]]; then tcp=$list; else udp=$list; fi
    say "$port/$proto: закрито" "$port/$proto: closed"
  done
  save "$tcp" "$udp"
}

cmd_status() {
  local p state banned ports
  if firewall_on && nft list table inet das >/dev/null 2>&1; then
    state=$(say "увімкнений" "on")
  else
    state=$(say "ВИМКНЕНИЙ (dsv enable nftables)" "OFF (dsv enable nftables)")
  fi
  say "Брандмауер: $state" "Firewall: $state"
  say "Вхідні з'єднання закриті, крім:" "Incoming connections are closed except:"
  echo "  22/tcp (ssh)"
  for p in tcp udp; do
    while read -r ports; do
      [[ -n $ports ]] && echo "  $ports/$p"
    done < <(saved_ports "$p")
  done

  # Docker opens its published ports itself
  if command -v docker >/dev/null && ports=$(docker ps --format '{{.Names}}: {{.Ports}}' 2>/dev/null) &&
    grep -q -- '->' <<<"$ports"; then
    say "Опубліковані контейнерами Docker (відкриті незалежно від dfw):" \
      "Published by Docker containers (open regardless of dfw):"
    grep -- '->' <<<"$ports" | sed 's/^/  /'
  fi

  if banned=$(fail2ban-client status sshd 2>/dev/null | sed -n 's/.*Currently banned:[[:space:]]*//p') &&
    [[ -n $banned && $banned != 0 ]]; then
    say "fail2ban: заблоковано адрес за невдалі входи SSH: $banned" \
      "fail2ban: addresses banned for failed SSH logins: $banned"
  fi
}

cmd=${1:-status}
[[ $# -gt 0 ]] && shift

case $cmd in
  status | list | ls)
    need_root status
    check_config
    cmd_status
    ;;
  allow | open)
    [[ $# -gt 0 ]] || die "вкажіть порт: dfw allow 80/tcp" "give a port: dfw allow 80/tcp"
    need_root allow "$@"
    check_config
    cmd_allow "$@"
    ;;
  deny | close)
    [[ $# -gt 0 ]] || die "вкажіть порт: dfw deny 80/tcp" "give a port: dfw deny 80/tcp"
    need_root deny "$@"
    check_config
    cmd_deny "$@"
    ;;
  reload)
    need_root reload
    nft -c -f "$conf"
    if firewall_on; then
      nft -f "$conf"
      say "правила завантажено" "rules loaded"
    else
      say "правила правильні, але брандмауер вимкнений: dsv enable nftables" \
        "the rules are valid, but the firewall is off: dsv enable nftables"
    fi
    ;;
  -h | --help | help) usage ;;
  *)
    usage >&2
    exit 1
    ;;
esac
