diff --git a/README.md b/README.md index 9d5b505..763fef5 100644 --- a/README.md +++ b/README.md @@ -265,6 +265,12 @@ If an agent needs a site or folder the sandbox blocks, add it in `~/.config/core The sandbox is Anthropic's [sandbox runtime](https://github.com/anthropics/sandbox-runtime), which uses bubblewrap and socat. The default rules are in [sandbox/config/](sandbox/config/). +## Open ports + +`audit` shows the network ports open on this computer. For each one it shows who can connect to it, after the firewall: only this computer, your Tailscale network, or your LAN. It also shows which service opened the port and why. + +You can stop a service from there, which also keeps it from starting at boot, or start a stopped one again. `update` leaves the services you stop off. audit won't stop the ones the desktop, Files or the network need. Run it with sudo rights so it can name the services run by root; `audit --list` only shows the ports. + ## mDNS Networking mDNS lets you reach other machines on your LAN as "machine.local", e.g. `ssh machine.local`. When systemd-resolved is enabled, the installer sets it up with [fixmdns.sh](fixmdns.sh): it turns on Avahi and adds nss-mdns to /etc/nsswitch.conf ahead of systemd-resolved, so the two don't conflict. On a machine without systemd-resolved, run `bash ~/.local/share/coredesktop/fixmdns.sh` yourself. See the [Arch wiki](https://wiki.archlinux.org/title/Avahi#Hostname_resolution) for more. diff --git a/audit b/audit new file mode 100755 index 0000000..76e7859 --- /dev/null +++ b/audit @@ -0,0 +1,378 @@ +#!/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. +{ + +COREDESKTOP="$HOME/.local/share/coredesktop" +source "$COREDESKTOP/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" + "cups.service|631/tcp|Printing (CUPS); only this machine can reach it" + "cups.socket|631/tcp|Printing (CUPS); only this machine can reach it" + "tailscaled.service|41641/udp|Tailscale: your Core's private network" + "docker.service||Docker" + "sonarr.service|8989/tcp|Sonarr: finds and downloads TV shows; its web page is on port 8989" + "hermes-gateway.service||The sandbox's proxy for Hermes Agent's gateway (srt); only this machine can reach it" + "gvfs-daemon.service||Files: finds Windows computers and shares on your network (WS-Discovery)" + "kdeconnectd||KDE Connect (GSConnect): links your phone to this computer" + "localsend||LocalSend: sends files to and from nearby devices" + "com.vscodium.codium||VSCodium: its extensions' local servers; only this machine can reach them" +) + +# Stopping these would break the desktop, Files or the network, so audit +# doesn't offer to +PROTECTED="gvfs-daemon.service systemd-resolved.service NetworkManager.service gdm.service dbus.service dbus-broker.service systemd-networkd.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 +} + +# Firewall rules that let connections in, as "proto port iface" ("any" for any +# protocol, port or interface), from ufw's rule files +load_firewall() { + FIREWALL_ON=0 + FIREWALL_RULES=() + grep -qs '^ENABLED=yes' /etc/ufw/ufw.conf && systemctl is-active --quiet ufw && FIREWALL_ON=1 + local action proto dport iface + while read -r _ _ _ action proto dport _ _ _ iface _; do + [[ "$action" == allow || "$action" == limit ]] || continue + iface="${iface#in}" + FIREWALL_RULES+=("$proto $dport ${iface#_}") + done < <(grep -hs '^### tuple ###' /etc/ufw/user.rules /etc/ufw/user6.rules) +} + +# Whether the firewall lets in on ("" for every interface) +firewall_allows() { + local proto="$1" port="$2" want="$3" rule rproto rport riface part + [ "$FIREWALL_ON" = 0 ] && return 0 + for rule in "${FIREWALL_RULES[@]}"; do + read -r rproto rport riface <<< "$rule" + [[ "$rproto" == any || "$rproto" == "$proto" ]] || continue + [[ -z "$riface" || "$riface" == "$want" ]] || continue + [ "$rport" = any ] && return 0 + for part in ${rport//,/ }; do + if [[ "$part" == *:* ]]; then + (( port >= ${part%:*} && port <= ${part#*:} )) && return 0 + elif [ "$part" = "$port" ]; then + return 0 + fi + done + done + return 1 +} + +# 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 +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 + ROW_PROTO=() ROW_PORT=() ROW_OWNER=() ROW_KINDS=() + + while read -r _ iface _ addr _; do + iface_of["${addr%/*}"]="$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="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 + if firewall_allows "$proto" "$port" ""; then parts+=("\e[31mLAN\e[0m"); else blocked=1; fi + fi + if [ "$ts" = 1 ] && firewall_allows "$proto" "$port" tailscale0; then + parts+=("\e[33mTailscale\e[0m") + fi + if [ "$ct" = 1 ] && firewall_allows "$proto" "$port" ""; 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 +} + +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 known ones that are installed but stopped +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 + systemctl cat "$unit" &> /dev/null || continue + is_active system "$unit" && continue + # cups.socket and cups.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 + echo + if [ "$FIREWALL_ON" = 1 ]; then + echo "Firewall (ufw) on: it blocks connections from other machines except to the ports it allows." + else + echo -e "\e[31mFirewall (ufw) off: every port listening beyond this machine can be reached.\e[0m" + 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]}")" + # printf counts the color codes as width, so pad by the visible length + visible="$(sed 's/\\e\[[0-9;]*m//g' <<< "$reach")" + printf "%-7s %-5s %b%*s %-26s %s\n" "${ROW_PORT[$key]}" "${ROW_PROTO[$key]}" \ + "$reach" $((34 - ${#visible})) "" "$(owner_label "${ROW_OWNER[$key]}")" "$(why "${ROW_OWNER[$key]}")" + 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 +} + +OFF_FILE="$HOME/.config/coredesktop/services-off" + +toggle() { # + local scope="$1" unit="$2" units=("$2") pair + if [[ " $PROTECTED " == *" $unit "* ]]; then + echo "$unit keeps the desktop or the 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 "$OFF_FILE")" + 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" "$OFF_FILE" || echo "$scope $unit" >> "$OFF_FILE" + 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" "$OFF_FILE" 2> /dev/null + done + fi +} + +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_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 +} diff --git a/install.sh b/install.sh index deb6420..8ae971a 100755 --- a/install.sh +++ b/install.sh @@ -53,13 +53,14 @@ cd ~/.local/share/coredesktop # Source utility functions (provides install_packages and other helpers) source utils.sh -# Install the refresh, update, sibling, version and configure scripts to the user's bin +# Install the refresh, update, sibling, version, configure and audit scripts to the user's bin mkdir -p ~/.local/bin install_script ~/.local/share/coredesktop/refresh ~/.local/bin/refresh install_script ~/.local/share/coredesktop/update ~/.local/bin/update install_script ~/.local/share/coredesktop/sibling ~/.local/bin/sibling install_script ~/.local/share/coredesktop/version ~/.local/bin/version install_script ~/.local/share/coredesktop/configure ~/.local/bin/configure +install_script ~/.local/share/coredesktop/audit ~/.local/bin/audit # Source the package list (defines SYSTEM_UTILS, DEV_TOOLS, DESKTOP, etc. arrays) if [ ! -f "packages.conf" ]; then @@ -118,10 +119,11 @@ install_packages "${FONTS[@]}" echo "Installing pipx applications..." install_pipx_apps "${PIPX[@]}" -# Enable all systemd services listed in SERVICES array, then register the SSH -# Avahi service so the machine is discoverable over mDNS +# Enable the services in SERVICES, and enable and start those in START_SERVICES, +# then register the SSH Avahi service so the machine is discoverable over mDNS echo "Configuring services..." enable_services "${SERVICES[@]}" +enable_services --now "${START_SERVICES[@]}" sudo cp /usr/share/doc/avahi/ssh.service /etc/avahi/services/ cd ~/.local/share/coredesktop diff --git a/packages.conf b/packages.conf index 014e710..839eebf 100644 --- a/packages.conf +++ b/packages.conf @@ -161,3 +161,10 @@ SERVICES=( NetworkManager.service sshd.service ) + +# Services to enable and also start straight away. The ones above start at the +# next boot: starting gdm or NetworkManager in the middle of an install would +# interrupt it. +START_SERVICES=( + sonarr.service +) diff --git a/update b/update index 0b0a013..b31e3a5 100755 --- a/update +++ b/update @@ -44,6 +44,7 @@ install_script "$COREDESKTOP/refresh" ~/.local/bin/refresh install_script "$COREDESKTOP/sibling" ~/.local/bin/sibling install_script "$COREDESKTOP/version" ~/.local/bin/version install_script "$COREDESKTOP/configure" ~/.local/bin/configure +install_script "$COREDESKTOP/audit" ~/.local/bin/audit if [[ -z "$UPDATE_REEXECED" ]]; then export UPDATE_REEXECED=1 diff --git a/updates.d/15-services.sh b/updates.d/15-services.sh index 1ce15f9..d8e84d1 100755 --- a/updates.d/15-services.sh +++ b/updates.d/15-services.sh @@ -7,3 +7,4 @@ source "$COREDESKTOP/packages.conf" echo "Checking for new services..." enable_services "${SERVICES[@]}" +enable_services --now "${START_SERVICES[@]}" diff --git a/utils.sh b/utils.sh index ac9115d..1d6c702 100755 --- a/utils.sh +++ b/utils.sh @@ -94,11 +94,22 @@ remove_flatpaks() { } # Function to enable systemd services if not already enabled +# With --now, also start the services enabled here, after making systemd read +# the unit files that packages just installed enable_services() { + local now=() + if [ "$1" = "--now" ]; then + now=(--now) + shift + sudo systemctl daemon-reload + fi for service in "$@"; do - if ! systemctl is-enabled "$service" &> /dev/null; then + # audit records the services you turn off; leave those off + if grep -qsx "system $service" "$HOME/.config/coredesktop/services-off"; then + echo "$service is turned off (see audit)." + elif ! systemctl is-enabled "$service" &> /dev/null; then echo "Enabling $service..." - sudo systemctl enable "$service" + sudo systemctl enable "${now[@]}" "$service" else echo "$service is already enabled." fi