librelad 9b9b2054d3 fix(install): ensure ip_tables modules before rootless Docker setup
get.docker.com/rootless aborts when ip_tables/ip6_tables aren't loaded.
Ubuntu 24.04 and 26.04 ship both modules but don't autoload them on a
fresh box, so rootless setup died there — and because the caller captures
its output into $result, the reason never reached the console or the
error report. The install continued, reported success, and printed
credentials for a WebUI that was never running.

initPrerequires now modprobes both modules and persists them to
/etc/modules-load.d/libreportal-rootless.conf for subsequent boots,
failing with an actionable message when the kernel genuinely lacks them
(container/VM kernels without netfilter).

installDockerRootless gets its own guard, since it also runs outside
init.sh via start_docker / rootless_start_setup. It only attempts
modprobe when it can — the de-sudoed manager has no modprobe in the
LP_SYSTEM allowlist, matching how ubuntu.sh handles sudo-apt — and
returns non-zero rather than proceeding into a failure whose message
would be swallowed. Already-loaded modules are a clean no-op, so the
normal post-install re-run path is unaffected.

Uninstall removes the drop-in alongside the sysctl ones, and it's listed
in the footprint summary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 11:08:32 +01:00

266 lines
13 KiB
Bash
Executable File

#!/bin/bash
installDockerRootless()
{
if [[ $CFG_DOCKER_INSTALL_TYPE == "rootless" ]]; then
isHeader "Install Docker Rootless"
#dockerComposeDownAllApps root;
#dockerServiceStop root;
((menu_number++))
echo ""
echo "---- $menu_number. Installing System Requirements."
echo ""
local docker_install_user_id=$(id -u "$CFG_DOCKER_INSTALL_USER")
local docker_install_bashrc="/home/$CFG_DOCKER_INSTALL_USER/.bashrc"
isNotice "Fetching and installing the necessary packages...this may take a moment..."
local result; result=$(runSystem apt-get install -y apt-transport-https ca-certificates curl gnupg software-properties-common uidmap dbus-user-session fuse-overlayfs passt)
checkSuccess "Installing necessary packages"
local result; result=$(runSystem systemctl disable --now docker.service docker.socket)
checkSuccess "Disabling Docker service & Socket"
((menu_number++))
echo ""
echo "---- $menu_number. Installing slirp4netns."
echo ""
# slirp4netns — ensure it's installed via apt (idempotent: installs if missing,
# no-op if already present). We install from apt, so there's no point chasing the
# GitHub-latest release.
local result; result=$(runSystem apt-get install -y slirp4netns)
checkSuccess "Installing slirp4netns"
# `slirp4netns --version` prints several lines (version, commit, libslirp,
# SLIRP_CONFIG_VERSION_MAX); read only the first line, field 3 is the number.
installed_version=$(slirp4netns --version 2>/dev/null | head -n1 | awk '{print $3}')
isSuccessful "slirp4netns ${installed_version:-installed} is ready"
# Debian 10 only. Use the values detectOS already resolved from /etc/os-release
# instead of shelling out to lsb_release, which isn't installed by default on
# minimal Ubuntu/Debian images (and would otherwise just error to stderr here).
if [[ "$OS_TYPE" == "Debian" && "$OS_VERSION" == "10" ]]; then
((menu_number++))
echo ""
echo "---- $menu_number. Updating the sysctl file for Updating Debian 10."
echo ""
if sudo grep -qs "kernel.unprivileged_userns_clone=1" $sysctl; then
isNotice "kernel.unprivileged_userns_clone=1 already exists in $sysctl"
else
local result; result=$(echo "kernel.unprivileged_userns_clone=1" | sudo tee -a $sysctl > /dev/null)
checkSuccess "Adding kernel.unprivileged_userns_clone=1 to $sysctl..."
local result; result=$(runSystem sysctl --system)
checkSuccess "Running runAsManager sysctl --system..."
fi
fi
((menu_number++))
echo ""
echo "---- $menu_number. Update the .bashrc file."
echo ""
if ! grep -qF "# DOCKER ROOTLESS BASHRC START" "$docker_install_bashrc"; then
local result; result=$(echo '# DOCKER ROOTLESS BASHRC START' | sudo tee -a "$docker_install_bashrc" > /dev/null)
checkSuccess "Adding rootless header to .bashrc"
local result; result=$(echo 'export XDG_RUNTIME_DIR=/run/user/${UID}' | sudo tee -a "$docker_install_bashrc" > /dev/null)
checkSuccess "Adding export path to .bashrc"
local result; result=$(echo 'export PATH=/usr/bin:$PATH' | sudo tee -a "$docker_install_bashrc" > /dev/null)
checkSuccess "Adding export path to .bashrc"
local result; result=$(echo 'export DOCKER_HOST=unix:///run/user/${UID}/docker.sock' | sudo tee -a "$docker_install_bashrc" > /dev/null)
checkSuccess "Adding export DOCKER_HOST path to .bashrc"
local result; result=$(echo 'export DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${UID}/bus"' | sudo tee -a "$docker_install_bashrc" > /dev/null)
checkSuccess "Adding export DBUS_SESSION_BUS_ADDRESS path to .bashrc"
local result; result=$(echo '# DOCKER ROOTLESS BASHRC END' | sudo tee -a "$docker_install_bashrc" > /dev/null)
checkSuccess "Adding rootless header to .bashrc"
isSuccessful "Added $CFG_DOCKER_INSTALL_USER to bashrc file"
else
isNotice "Rootless .bashrc already configured for $CFG_DOCKER_INSTALL_USER — skipping"
fi
((menu_number++))
echo ""
echo "---- $menu_number. Setting up Rootless Docker."
echo ""
local result; result=$(runSystem loginctl enable-linger $CFG_DOCKER_INSTALL_USER)
checkSuccess "Adding automatic start (linger)"
# get.docker.com/rootless aborts outright when the legacy ip_tables
# modules aren't loaded — and its output is captured into $result below,
# so the real reason never reaches the console or the error report; the
# install just carries on and "succeeds" with no daemon. Ubuntu 24.04 and
# 26.04 ship these modules but don't autoload them on a fresh box.
# init.sh loads + persists them at install time; a later re-run by the
# de-sudoed manager can't modprobe (not in the LP_SYSTEM allowlist), so
# only attempt it when we actually can, and stop loudly when we can't.
local mod missing=()
for mod in ip_tables ip6_tables; do
lsmod 2>/dev/null | grep -q "^${mod}\b" && continue
sudo -n modprobe "$mod" 2>/dev/null || missing+=("$mod")
done
if [[ ${#missing[@]} -gt 0 ]]; then
isError "Rootless Docker needs kernel module(s) that aren't loaded: ${missing[*]}"
isNotice "Load now: sudo modprobe ${missing[*]}"
isNotice "Persist it: add one module per line to $modules_load"
isNotice "Stopping before rootless setup — it would fail with its error swallowed."
return 1
fi
# Install rootless Docker and enable the user service, but do NOT
# start it here. The rootless network override (override.conf,
# written further down) and the daemon-reload that picks it up
# haven't happened yet, so a start at this point comes up with no
# valid net/port-driver config and fails ("Job for docker.service
# failed") — a guaranteed, harmless first-start error that only
# noises the error report. The first real start is the
# `systemctl --user restart docker` below, once the override is in
# place.
rootless_install=$(cat <<EOF
curl -fsSL https://get.docker.com/rootless | sh && \
systemctl --user enable docker && \
exit
EOF
)
local result; result=$(dockerCommandRunInstallUser "$rootless_install")
checkSuccess "Setting up Rootless for $CFG_DOCKER_INSTALL_USER"
((menu_number++))
# The net namespace driver and the rootlesskit port driver are a matched
# pair — mixing them makes rootlesskit reject the config and the daemon
# silently never starts (this is why an earlier pasta+builtin attempt
# bricked the install). The user picks one network stack via
# CFG_ROOTLESS_NET; we derive its only valid port driver here:
# pasta -> implicit (fastest; propagates the real client IP)
# slirp4netns -> builtin (legacy fallback)
local rootless_net="${CFG_ROOTLESS_NET:-pasta}"
local rootless_port_driver
case "$rootless_net" in
pasta) rootless_port_driver="implicit" ;;
slirp4netns) rootless_port_driver="builtin" ;;
*)
isNotice "Unknown CFG_ROOTLESS_NET='$rootless_net'; falling back to pasta."
rootless_net="pasta"
rootless_port_driver="implicit"
;;
esac
echo ""
echo "---- $menu_number. Configuring rootless networking ($rootless_net + $rootless_port_driver port driver)."
echo ""
systemd_user_dir="/home/$CFG_DOCKER_INSTALL_USER/.config/systemd/user"
local result; result=$(dockerCommandRunInstallUser "mkdir -p $systemd_user_dir")
checkSuccess "Create the systemd user directory if it doesn't exist"
local result; result=$(dockerCommandRunInstallUser "mkdir -p $systemd_user_dir/docker.service.d")
checkSuccess "Create the docker.service.d directory if it doesn't exist"
override_conf_file="$systemd_user_dir/docker.service.d/override.conf"
local result; result=$(sudo touch $override_conf_file)
checkSuccess "Create the override.conf in docker.service.d"
# Resolve CFG_NETWORK_MTU to a concrete, path-safe value (auto = probe).
# This is the MTU the daemon PULLS over — a blind 1500 on a lower-path-MTU
# link (Qubes/NAT/VPN) black-holes large image-layer downloads (EOF).
local resolved_mtu; resolved_mtu="$(networkRedetectMtu)"
isNotice "Rootless uplink MTU: $resolved_mtu"
sudo bash -c "cat <<EOL > '$override_conf_file'
[Service]
Environment='DOCKERD_ROOTLESS_ROOTLESSKIT_NET=$rootless_net'
Environment='DOCKERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER=$rootless_port_driver'
Environment='DOCKERD_ROOTLESS_ROOTLESSKIT_MTU=$resolved_mtu'
EOL"
local result; result=$(sudo chown $CFG_DOCKER_INSTALL_USER:$CFG_DOCKER_INSTALL_USER $override_conf_file)
checkSuccess "Updating ownership for override.conf"
# Pasta needs explicit AppArmor permissions that the Debian-shipped
# passt profile doesn't include by default (ptrace_read on the
# rootlesskit child + a couple of /proc + /run paths). Skip silently
# when slirp4netns is selected — that path doesn't go through passt.
if [[ "$rootless_net" == "pasta" ]]; then
installRootlessApparmorForPasta
fi
# NOTE: we deliberately do NOT set "userland-proxy": false here. Disabling
# it makes rootless dockerd require br_netfilter
# (/proc/sys/net/bridge/bridge-nf-call-iptables), which isn't present in
# the rootless netns on Debian — the daemon then fails to create the
# default bridge and won't start. The userland proxy's lack of source-IP
# propagation doesn't matter here: apps sit behind Traefik, which carries
# the real client IP via X-Forwarded-For at L7.
local result; result=$(dockerCommandRunInstallUser "systemctl --user daemon-reload")
checkSuccess "Reload the systemd user manager configuration"
isNotice "Restarting docker service...this may take a moment..."
local result; result=$(dockerCommandRunInstallUser "systemctl --user restart docker")
checkSuccess "Reload the systemd user docker service"
# The restart above runs the daemon's container-restore pass, which
# resurrects containers left over from a previous install whose project
# directory this install already wiped. Clear them now so the next
# restart can't re-create their bind-mount sources as stub directories.
dockerRemoveStrandedContainers "loud"
local result; result=$(sudo cp $sysctl $sysctl.bak)
checkSuccess "Backing up sysctl file"
((menu_number++))
echo ""
echo "---- $menu_number. Setting up sysctl file to work with LetsEncrypt."
echo ""
# Update sysctl file
if ! grep -qsF "# DOCKER ROOTLESS SYSCTL START" "$sysctl"; then
local result; result=$(echo '# DOCKER ROOTLESS SYSCTL START' | sudo tee -a "$sysctl" > /dev/null)
checkSuccess "Adding rootless header to sysctl"
local result; result=$(echo 'net.ipv4.ip_unprivileged_port_start=0' | sudo tee -a "$sysctl" > /dev/null)
checkSuccess "Adding ip_unprivileged_port_start to sysctl"
local result; result=$(echo 'kernel.unprivileged_userns_clone=1' | sudo tee -a "$sysctl" > /dev/null)
checkSuccess "Adding unprivileged_userns_clone to sysctl"
local result; result=$(echo '# DOCKER ROOTLESS SYSCTL END' | sudo tee -a "$sysctl" > /dev/null)
checkSuccess "Adding rootless end to sysctl"
isSuccessful "Updated the sysctl with Docker Rootless configuration"
fi
# Enabling unprivileged user namespaces (needed for rootless) widens the
# kernel attack surface reachable by unprivileged users. Offset that by
# closing the surfaces that local-privilege-escalation chains lean on:
# kptr_restrict hides kernel pointers (info-leak primitives), ptrace_scope
# blocks cross-process ptrace (credential theft post-compromise), and
# bpf_jit_harden hardens the JIT against spraying. Written under
# /etc/sysctl.d/ so `sysctl --system` actually loads it — LibrePortal's
# own $sysctl path (/etc/sysctl/…) is non-standard and is NOT read by
# `sysctl --system`.
local hardening_conf="/etc/sysctl.d/99-libreportal-hardening.conf"
sudo bash -c "cat > '$hardening_conf'" <<'EOL'
# LibrePortal kernel LPE-surface hardening (paired with rootless unprivileged userns)
kernel.kptr_restrict=2
kernel.yama.ptrace_scope=1
net.core.bpf_jit_harden=2
EOL
checkSuccess "Writing kernel LPE-surface hardening to $hardening_conf"
local result; result=$(runSystem sysctl --system)
checkSuccess "Applying changes to sysctl"
menu_number=0
fi
}