#!/bin/bash # # Purpose : Show the network ports open on this machine: who can reach each one # through the firewall, which service opened it and why, and let you # stop or start those services. # # A service you stop here is also kept from starting at boot, and update leaves # it off (see enable_services in utils.sh) until you start it here again. # Wrapped in { ...; exit; } so bash reads the whole script up front; update # replaces this file while it runs. { MINISERVER="$HOME/.local/share/MiniServer" # Provides firewall_entries and SERVICES_OFF source "$MINISERVER/utils.sh" # Why a service or app opens ports, by systemd unit or process name. Services # with ports here are also listed while they're stopped, so they can be started. # Format: name|ports|why KNOWN=( "sshd.service|22/tcp|SSH: log in to this machine from other machines" "systemd-resolved.service||Looks up domain names (DNS); 5355 is LLMNR, which answers name lookups from the LAN" "avahi-daemon.service|5353/udp|mDNS: lets machines on your LAN reach this one as $(uname -n).local" "cockpit.socket|9090/tcp|Cockpit: manage this server from a web browser" "cockpit.service|9090/tcp|Cockpit: manage this server from a web browser" "tailscaled.service|41641/udp|Tailscale: your Core's private network" "docker.service||Docker" "pm2-$USER.service||pm2: keeps your Node apps running; each app opens its own ports" "hermes-gateway.service||The sandbox's proxy for Hermes Agent's gateway (srt); only this machine can reach it" ) # Stopping these would break the server's network, its firewall or the # graphical login, so audit doesn't offer to PROTECTED="systemd-resolved.service NetworkManager.service systemd-networkd.service firewalld.service sddm.service dbus.service dbus-broker.service" usage() { cat << EOF Usage: audit [--list] audit Show the open ports and choose a service to stop or start audit --list Only show the open ports Run it with sudo rights: without them, the services run by root can't be named. EOF } known_field() { # local entry for entry in "${KNOWN[@]}"; do [ "${entry%%|*}" = "$1" ] && { cut -d'|' -f"$2" <<< "$entry"; return 0; } done return 1 } # The running known service that uses , for a socket whose # process only root can see known_port_owner() { local entry unit for entry in "${KNOWN[@]}"; do unit="${entry%%|*}" [[ ",$(cut -d'|' -f2 <<< "$entry")," == *",$2/$1,"* ]] || continue systemctl is-active --quiet "$unit" 2> /dev/null && { echo "system $unit"; return 0; } done return 1 } # firewall-cmd, which only answers while firewalld runs fw() { if [ -n "$NO_SUDO" ]; then firewall-cmd "$@"; else sudo firewall-cmd "$@"; fi } # The ports a firewalld service opens, as "proto port" lines in # SERVICE_PORTS[service], with those of the services it includes declare -A SERVICE_PORTS cache_service() { # [ -n "${SERVICE_PORTS[$1]+set}" ] && return # Set first, so services that include each other don't loop SERVICE_PORTS[$1]="" local info entry inc ports="" info="$(fw --info-service="$1" 2> /dev/null)" for entry in $(sed -n 's/^ *ports: //p' <<< "$info"); do ports+="${entry#*/} ${entry%/*}"$'\n' done for inc in $(sed -n 's/^ *includes: //p' <<< "$info"); do cache_service "$inc" ports+="${SERVICE_PORTS[$inc]}" done SERVICE_PORTS[$1]="$ports" } # Add " " lines to ZONE_RULES[zone] for a service, where # level is full, or some when only some source addresses are let in add_service_rules() { # local proto port cache_service "$3" while read -r proto port; do [ -n "$proto" ] && ZONE_RULES[$1]+="$2 $proto $port"$'\n' done <<< "${SERVICE_PORTS[$3]}" } # What firewalld lets in, from every zone: ZONE_RULES[zone] holds # " " lines ("any" for any protocol or port; a port can be # a range), ZONE_OF_IFACE the zone each interface was put in (others are in # DEFAULT_ZONE), and SOURCE_ZONES the zones that apply to some source # addresses on every interface load_firewall() { FIREWALL_ON=0 FIREWALL_UNREAD=0 DEFAULT_ZONE="" declare -gA ZONE_RULES ZONE_OF_IFACE ZONE_RULES=() ZONE_OF_IFACE=() SOURCE_ZONES=() systemctl is-active --quiet firewalld && FIREWALL_ON=1 [ "$FIREWALL_ON" = 1 ] || return 0 local all line zone key value level item if ! all="$(fw --list-all-zones 2> /dev/null)"; then FIREWALL_UNREAD=1 return 0 fi while IFS= read -r line; do # A zone starts unindented, e.g. "public (default, active)" if [[ "$line" =~ ^([^[:space:]]+)( \((.*)\))?$ ]]; then zone="${BASH_REMATCH[1]}" [[ "${BASH_REMATCH[3]}" == *default* ]] && DEFAULT_ZONE="$zone" continue fi [ -n "$zone" ] || continue # Rich rules, one per line after "rich rules:". Only those that accept # connections count; one limited to a source lets in only some addresses. if [[ "$line" =~ ^[[:space:]]+rule[[:space:]] ]]; then [[ "$line" =~ [[:space:]]accept([[:space:]]|$) ]] || continue level=full [[ "$line" == *"source address="* || "$line" == *"source ipset="* || "$line" == *"source mac="* ]] && level=some if [[ "$line" =~ [[:space:]]port\ port=\"([0-9-]+)\"\ protocol=\"([a-z]+)\" ]]; then ZONE_RULES[$zone]+="$level ${BASH_REMATCH[2]} ${BASH_REMATCH[1]}"$'\n' elif [[ "$line" =~ service\ name=\"([^\"]+)\" ]]; then add_service_rules "$zone" "$level" "${BASH_REMATCH[1]}" else ZONE_RULES[$zone]+="$level any any"$'\n' fi continue fi [[ "$line" =~ ^[[:space:]]+([a-z -]+):[[:space:]]?(.*)$ ]] || continue key="${BASH_REMATCH[1]}" value="${BASH_REMATCH[2]}" case "$key" in target) [ "$value" = ACCEPT ] && ZONE_RULES[$zone]+="full any any"$'\n' ;; interfaces) for item in $value; do ZONE_OF_IFACE[$item]="$zone"; done ;; sources) [ -n "$value" ] && SOURCE_ZONES+=("$zone") ;; services) for item in $value; do add_service_rules "$zone" full "$item"; done ;; ports) for item in $value; do ZONE_RULES[$zone]+="full ${item#*/} ${item%/*}"$'\n'; done ;; esac done <<< "$all" } # How a zone lets in: full, some (from some addresses) or "" zone_allows() { # local level proto port best="" while read -r level proto port; do [ -n "$level" ] || continue [[ "$proto" == any || "$proto" == "$2" ]] || continue [[ "$port" == any ]] || (( $3 >= ${port%-*} && $3 <= ${port#*-} )) || continue [ "$level" = full ] && { echo full; return; } best=some done <<< "${ZONE_RULES[$1]}" echo "$best" } # How the firewall lets in from the network of (lan, # tailscale or containers): full, some or "" firewall_allows() { # local iface level best="" zone if [ "$FIREWALL_ON" = 0 ] || [ "$FIREWALL_UNREAD" = 1 ]; then [ -n "${IFACES_OF_KIND[$3]}" ] && echo full return fi for iface in ${IFACES_OF_KIND[$3]}; do level="$(zone_allows "${ZONE_OF_IFACE[$iface]:-$DEFAULT_ZONE}" "$1" "$2")" [ "$level" = full ] && { echo full; return; } [ -n "$level" ] && best=some done # A zone bound to source addresses lets them in on any interface if [ -n "${IFACES_OF_KIND[$3]}" ]; then for zone in "${SOURCE_ZONES[@]}"; do [ -n "$(zone_allows "$zone" "$1" "$2")" ] && best=some done fi echo "$best" } # The ports each firewall.conf asks for, as " " in # CONF_PORTS, with services expanded to their ports load_firewall_confs() { local conf name entry proto port CONF_PORTS=() # Only shown alongside the firewall's rules [ "$FIREWALL_ON" = 1 ] && [ "$FIREWALL_UNREAD" = 0 ] || return 0 for conf in "$MINISERVER/firewall.conf" "$HOME/.local/share/siblings/"*/firewall.conf; do [ -f "$conf" ] || continue name=MiniServer [[ "$conf" == */siblings/* ]] && name="$(basename "$(dirname "$conf")")" while IFS= read -r entry; do case "$entry" in port:*) entry="${entry#port:}" CONF_PORTS+=("${entry#*/} ${entry%/*} $name") ;; service:*) entry="${entry#service:}" cache_service "$entry" while read -r proto port; do [ -n "$proto" ] && CONF_PORTS+=("$proto $port $name") done <<< "${SERVICE_PORTS[$entry]}" ;; esac done < <(firewall_entries "$conf" 2> /dev/null) done } # The projects whose firewall.conf asks for , comma-separated conf_owners() { # local entry proto port name names="" [[ "$2" =~ ^[0-9]+$ ]] || return 0 for entry in "${CONF_PORTS[@]}"; do read -r proto port name <<< "$entry" [ "$proto" = "$1" ] || continue (( $2 >= ${port%-*} && $2 <= ${port#*-} )) || continue [[ ", $names, " == *", $name, "* ]] || names+="${names:+, }$name" done echo "$names" } # Which network an interface is on: local, lan, tailscale or containers iface_kind() { case "$1" in lo) echo local ;; tailscale*) echo tailscale ;; docker*|br-*|veth*|virbr*) echo containers ;; *) echo lan ;; esac } # The systemd unit a process runs in, as "system ", "user ", # "app " or "none " process_owner() { local pid="$1" comm="$2" cgroup unit cgroup="$(cut -d: -f3- "/proc/$pid/cgroup" 2> /dev/null | head -n 1)" unit="${cgroup##*/}" case "$cgroup" in */app-flatpak-*.scope) unit="${unit#app-flatpak-}" echo "app ${unit%-*.scope}" ;; *.scope) echo "app $comm" ;; /system.slice/*.service) echo "system $unit" ;; /user.slice/*/user@*.service/*.service) echo "user $unit" ;; *) echo "none $comm" ;; esac } # A port systemd itself listens on, for a service it starts on demand socket_unit() { # systemctl list-sockets --all --no-legend 2> /dev/null \ | awk -v port="$1" '$1 ~ ":" port "$" { print $2; exit }' } # Collect the listening sockets into ROWS, one per service and port, with the # networks each listens on; and the interfaces on each network into # IFACES_OF_KIND load_ports() { local -A iface_of seen local netid local_addr process addr port iface kind pid comm owner key ROW_KEYS=() declare -gA ROW_PROTO ROW_PORT ROW_OWNER ROW_KINDS IFACES_OF_KIND ROW_PROTO=() ROW_PORT=() ROW_OWNER=() ROW_KINDS=() IFACES_OF_KIND=() while read -r _ iface _ addr _; do iface_of["${addr%/*}"]="$iface" kind="$(iface_kind "$iface")" [[ " ${IFACES_OF_KIND[$kind]} " == *" $iface "* ]] || IFACES_OF_KIND[$kind]+=" $iface" done < <(ip -o addr show 2> /dev/null) while read -r netid _ _ _ local_addr _ process; do port="${local_addr##*:}" addr="${local_addr%:*}" addr="${addr#[}" addr="${addr%]}" iface="" [[ "$addr" == *%* ]] && iface="${addr#*%}" addr="${addr%%%*}" case "$addr" in 0.0.0.0|::|\*) kind=all ;; 127.*|::1) kind=local ;; *) kind="$(iface_kind "${iface:-${iface_of[$addr]:-lan}}")" ;; esac pid="$(grep -o 'pid=[0-9]*' <<< "$process" | head -n 1 | cut -d= -f2)" comm="$(sed -nE 's/^users:\(\("([^"]*)".*/\1/p' <<< "$process")" if [ -z "$pid" ]; then owner="$(known_port_owner "$netid" "$port")" || owner="none ?" elif [ "$comm" = systemd ] && unit="$(socket_unit "$port")" && [ -n "$unit" ]; then owner="system $unit" elif [ "$comm" = docker-proxy ]; then owner="none docker-proxy" else owner="$(process_owner "$pid" "$comm")" fi # Random high ports bound to one address are a service's replies and # discovery, not something to connect to: one row for all of them [[ "$kind" != all && "$port" -ge 32768 ]] && port=random key="$netid $port $owner" if [ -z "${seen[$key]}" ]; then seen[$key]=1 ROW_KEYS+=("$key") ROW_PROTO[$key]="$netid" ROW_PORT[$key]="$port" ROW_OWNER[$key]="$owner" fi [[ " ${ROW_KINDS[$key]} " == *" $kind "* ]] || ROW_KINDS[$key]+=" $kind" done < <($SS -tulpnH 2> /dev/null) } # Who can connect to a row's port, given where it listens and the firewall reach_label() { # local proto="$1" port="$2" kinds="$3" lan=0 ts=0 ct=0 blocked=0 parts=() [ "$port" = random ] && port=0 for kind in $kinds; do case "$kind" in all) lan=1 ts=1 ;; lan) lan=1 ;; tailscale) ts=1 ;; containers) ct=1 ;; esac done if [ "$lan" = 1 ]; then # Docker opens the ports containers publish itself, ahead of firewalld if [ "$4" = "none docker-proxy" ]; then parts+=("\e[31mLAN\e[0m (past the firewall)") else case "$(firewall_allows "$proto" "$port" lan)" in full) parts+=("\e[31mLAN\e[0m") ;; some) parts+=("\e[31mLAN\e[0m (some addresses)") ;; *) blocked=1 ;; esac fi fi if [ "$ts" = 1 ] && [ -n "$(firewall_allows "$proto" "$port" tailscale)" ]; then parts+=("\e[33mTailscale\e[0m") fi if [ "$ct" = 1 ] && [ -n "$(firewall_allows "$proto" "$port" containers)" ]; then parts+=("\e[33mcontainers\e[0m") fi [ ${#parts[@]} -eq 0 ] && parts=("\e[32mthis machine\e[0m") local label="${parts[0]}" part for part in "${parts[@]:1}"; do label+=", $part"; done [ "$blocked" = 1 ] && label+=" (firewall blocks LAN)" echo -n "$label" } owner_label() { local scope="${1%% *}" name="${1#* }" case "$scope" in system) echo "${name%.service}" ;; user) echo "${name%.service} (yours)" ;; app) echo "$name (app)" ;; *) [ "$name" = docker-proxy ] && echo "Docker container" || echo "$name" ;; esac } why() { local scope="${1%% *}" name="${1#* }" text text="$(known_field "$name" 3)" && { echo "$text"; return; } case "$scope" in system) systemctl show -p Description --value "$name" 2> /dev/null ;; user) systemctl --user show -p Description --value "$name" 2> /dev/null ;; none) case "$name" in "?") echo "Run audit with sudo rights to see which service this is" ;; docker-proxy) echo "A port a Docker container publishes; see: docker ps" ;; esac ;; esac } # For a port other machines on the LAN can reach: which firewall.conf asks for # it, or a warning that none does. Containers' ports needn't be listed. conf_note() { # local owners [ "$3" = "none docker-proxy" ] && return owners="$(conf_owners "$1" "$2")" if [ -n "$owners" ]; then echo -n " [firewall.conf: $owners]" else echo -n " \e[31m[in no firewall.conf]\e[0m" fi } systemctl_for() { # if [ "$1" = user ]; then systemctl --user "${@:2}"; else sudo systemctl "${@:2}"; fi } is_active() { # if [ "$1" = user ]; then systemctl --user is-active --quiet "$2"; else systemctl is-active --quiet "$2"; fi } # Services that can be stopped or started here, as "scope unit": those with # open ports, then the known ones that are installed load_services() { local key owner entry unit SERVICES_LIST=() for key in "${ROW_KEYS[@]}"; do owner="${ROW_OWNER[$key]}" [[ "$owner" == system\ * || "$owner" == user\ * ]] || continue [[ " $PROTECTED " == *" ${owner#* } "* ]] && continue [[ " ${SERVICES_LIST[*]} " == *" $owner "* ]] || SERVICES_LIST+=("$owner") done for entry in "${KNOWN[@]}"; do unit="${entry%%|*}" [[ "$unit" == *.service || "$unit" == *.socket ]] || continue [ -n "$(known_field "$unit" 2)" ] || continue [[ " ${SERVICES_LIST[*]} " == *" system $unit "* ]] && continue # Listed even while running without its port open yet, as right after # starting, so it doesn't seem to vanish systemctl cat "$unit" &> /dev/null || continue # cockpit.socket and cockpit.service are one service to the user [[ "$unit" == *.socket && " ${SERVICES_LIST[*]} " == *" system ${unit%.socket}.service "* ]] && continue [[ "$unit" == *.service && " ${SERVICES_LIST[*]} " == *" system ${unit%.service}.socket "* ]] && continue SERVICES_LIST+=("system $unit") done } show_ports() { local key reach note text echo if [ "$FIREWALL_ON" = 0 ]; then echo -e "\e[31mFirewall (firewalld) off: every port listening beyond this machine can be reached.\e[0m" elif [ "$FIREWALL_UNREAD" = 1 ]; then echo -e "\e[33mFirewall (firewalld) on, but its rules couldn't be read: ports are shown as if it let everything in.\e[0m" else echo "Firewall (firewalld) on: it blocks connections from other machines except to the ports its zones allow." echo "Ports the LAN can reach show which firewall.conf asks for them (MiniServer's or a sibling project's)." fi [ -n "$NO_SUDO" ] && echo "Without sudo rights, the services run by root show as \"?\"." echo printf "\e[1m%-7s %-5s %-34s %-26s %s\e[0m\n" PORT PROTO "OPEN TO" SERVICE WHY local sorted visible # By port, with the random ports last mapfile -t sorted < <(for key in "${ROW_KEYS[@]}"; do printf '%s\t%s\n' "${ROW_PORT[$key]/random/99999}" "$key"; done | sort -n -k1,1 | cut -f2-) for key in "${sorted[@]}"; do reach="$(reach_label "${ROW_PROTO[$key]}" "${ROW_PORT[$key]}" "${ROW_KINDS[$key]}" "${ROW_OWNER[$key]}")" # printf counts the color codes as width, so pad by the visible length visible="$(sed 's/\\e\[[0-9;]*m//g' <<< "$reach")" note="" if [[ "$visible" == *LAN* && "$visible" != *"blocks LAN"* && "$FIREWALL_ON" = 1 && "$FIREWALL_UNREAD" = 0 ]]; then note="$(conf_note "${ROW_PROTO[$key]}" "${ROW_PORT[$key]}" "${ROW_OWNER[$key]}")" fi text="$(why "${ROW_OWNER[$key]}")" [ -z "$text" ] && note="${note# }" printf "%-7s %-5s %b%*s %-26s %s%b\n" "${ROW_PORT[$key]}" "${ROW_PROTO[$key]}" \ "$reach" $((34 - ${#visible})) "" "$(owner_label "${ROW_OWNER[$key]}")" "$text" "$note" done } show_services() { local i scope unit state echo echo "Services you can stop or start:" echo for i in "${!SERVICES_LIST[@]}"; do scope="${SERVICES_LIST[$i]%% *}" unit="${SERVICES_LIST[$i]#* }" if is_active "$scope" "$unit"; then state="\e[32mrunning\e[0m"; else state="\e[33mstopped\e[0m"; fi printf " %2d) %-30b %b\n" $((i + 1)) "$(owner_label "${SERVICES_LIST[$i]}")" "$state" done } toggle() { # local scope="$1" unit="$2" units=("$2") pair if [[ " $PROTECTED " == *" $unit "* ]]; then echo "$unit keeps the server or its network working, so audit won't stop it." return 1 fi if [ "$unit" = sshd.service ] && [ -n "$SSH_CONNECTION" ] && is_active "$scope" "$unit"; then echo "You're logged in over SSH: stopping sshd would lock you out." return 1 fi # A service started on demand has a socket unit too; both go together case "$unit" in *.socket) pair="${unit%.socket}.service" ;; *.service) pair="${unit%.service}.socket" ;; esac systemctl_for "$scope" cat "$pair" &> /dev/null && units+=("$pair") mkdir -p "$(dirname "$SERVICES_OFF")" if is_active "$scope" "$unit"; then confirm "Stop ${units[*]} and keep it from starting at boot?" || return 0 systemctl_for "$scope" disable --now "${units[@]}" || return 1 for unit in "${units[@]}"; do grep -qsx "$scope $unit" "$SERVICES_OFF" || echo "$scope $unit" >> "$SERVICES_OFF" done else confirm "Start ${units[*]} and start it at boot?" || return 0 systemctl_for "$scope" enable --now "${units[@]}" || return 1 for unit in "${units[@]}"; do sed -i "\|^$scope $unit\$|d" "$SERVICES_OFF" 2> /dev/null done wait_for_ports "$2" fi } # A service takes a moment to open its ports once started: wait up to 10 # seconds for its known ones, so the table shows them wait_for_ports() { # local ports port proto ports="$(known_field "$1" 2)" || return 0 for port in ${ports//,/ }; do proto="${port#*/}" port="${port%/*}" echo "Waiting for $1 to open port $port..." for _ in $(seq 40); do [ -n "$(ss -lnH "--$proto" "sport = :$port" 2> /dev/null)" ] && break sleep 0.25 done done } confirm() { local answer read -rp "$1 [y/N] " answer [[ "$answer" == [yY]* ]] } case "$1" in ""|-l|--list) ;; -h|--help) usage; exit 0 ;; *) usage; exit 2 ;; esac # Root's sockets name their process only to root SS="sudo ss" NO_SUDO="" if ! sudo -v; then SS="ss" NO_SUDO=1 fi while true; do load_firewall load_firewall_confs load_ports show_ports [ -n "$1" ] && exit 0 load_services show_services echo echo " q) Quit" echo while true; do read -rp "Choose a service to stop or start: " choice || exit 0 case "$choice" in q|Q|"") exit 0 ;; *[!0-9]*) ;; *) if (( choice >= 1 && choice <= ${#SERVICES_LIST[@]} )); then entry="${SERVICES_LIST[$((choice - 1))]}" toggle "${entry%% *}" "${entry#* }" break fi ;; esac echo "Choose a number from the list, or q." done done }