dotfiles/boot-setup.org

16 KiB

Boot Setup & Low-Level Configuration on Slackware

Overview

This is a writeup of how I have my Slackware laptop configured at the low level — boot services, input devices, audio, remote filesystems, and window manager. The goal throughout has been to keep everything in ~/.dotfiles as static files, managed by a pair of symlink scripts (link.sh for user configs, link-root.sh for system configs), and to avoid systemd or any init system that requires service descriptors to express dependencies.

I run Slackware at runlevel 3 (multi-user, no X). I log in on a TTY and start stumpwm or kde manually via startx, which means my X session is entirely optional and can be restarted or switched without rebooting. Several services — keyboard remapping, VPN, a network filesystem, and a music daemon — start at boot via rc.local, before any user session begins.

The Boot Sequence

Slackware uses BSD-style rc.d shell scripts. There's no dependency tracking, no parallelism, no After= directives. Everything in /etc/rc.d/rc.local runs sequentially, in order, as root.

My rc.local looks like this:

#!/bin/bash

# Log all boot output to file while still showing on console
exec > >(tee /var/log/rc.local.log) 2>&1

if [ -x /etc/rc.d/rc.kanata ]; then
    /etc/rc.d/rc.kanata start
fi

if [ -x /etc/rc.d/rc.tailscale ]; then
    /etc/rc.d/rc.tailscale start
fi

if [ -x /etc/rc.d/rc.autofs ]; then
    /etc/rc.d/rc.autofs start
fi

if [ -x /etc/rc.d/rc.mpd ]; then
    /etc/rc.d/rc.mpd start
fi

The exec > >(tee ...) line redirects all subsequent stdout and stderr to both the console and /var/log/rc.local.log, which is invaluable for debugging boot failures without having to attach a serial console or dig through kernel logs.

The services start in dependency order: kanata first (it only needs the kernel input device), then tailscale (needs the network), then autofs (needs tailscale to reach the NFS server), then MPD (needs autofs to have mounted the music library).

Keyboard Remapping with Kanata

I use kanata for keyboard remapping. It operates at the kernel input layer via evdev, entirely independent of X — which means my remaps work in TTYs, in tmux over SSH, everywhere.

The rc script at ~/.dotfiles/config/kanata/rc.kanata is symlinked to /etc/rc.d/rc.kanata by link-root.sh:

#!/bin/bash

KANATA_BIN="/home/green/.cargo/bin/kanata"
KANATA_CFG="/home/green/.config/kanata/kanata.kbd"
PIDFILE="/var/run/kanata.pid"

case "$1" in
  start)
    echo "Starting kanata..."
    # Clear caps lock state before grabbing device
    DISPLAY=:0 XAUTHORITY=/home/green/.Xauthority xset led off 2>/dev/null || true
    sleep 0.2
    $KANATA_BIN --cfg $KANATA_CFG &
    echo $! > $PIDFILE
    ;;
  stop)
    kill $(cat $PIDFILE) && rm -f $PIDFILE
    ;;
  restart)
    $0 stop; sleep 1; $0 start
    ;;
esac

Kanata is installed from source via cargo. Because it's a Rust binary in ~/.cargo/bin, the full path is hardcoded in the rc script — no PATH games at boot.

Tailscale VPN

Tailscale provides a WireGuard-based mesh VPN. I use it primarily to reach my home server (tower) from anywhere without port forwarding or a fixed IP. tailscaled is the userspace daemon; I installed the binary directly from a release tarball into /usr/sbin/ and /usr/bin/.

The rc script at ~/.dotfiles/config/tailscale/rc.tailscale:

#!/bin/bash

TAILSCALED=/usr/sbin/tailscaled
STATEDIR=/var/lib/tailscale
PIDFILE=/var/run/tailscaled.pid

start() {
    if [ -f "$PIDFILE" ] && kill -0 "$(cat $PIDFILE)" 2>/dev/null; then
        echo "tailscaled is already running"; return
    fi
    echo "Starting tailscaled..."
    mkdir -p /var/run/tailscale
    $TAILSCALED --state=$STATEDIR/tailscaled.state \
                --socket=/var/run/tailscale/tailscaled.sock \
                --port=41641 \
                &>/var/log/tailscaled.log &
    echo $! > $PIDFILE
    echo "tailscaled started (pid $(cat $PIDFILE))"
}

stop() {
    [ -f "$PIDFILE" ] && kill "$(cat $PIDFILE)" 2>/dev/null && rm -f "$PIDFILE"
}

case "$1" in
    start)   start ;;
    stop)    stop ;;
    restart) stop; sleep 1; start ;;
    status)
        if [ -f "$PIDFILE" ] && kill -0 "$(cat $PIDFILE)" 2>/dev/null; then
            echo "tailscaled is running (pid $(cat $PIDFILE))"
        else
            echo "tailscaled is not running"
        fi ;;
esac

DNS Caveat

Tailscale's MagicDNS (which would let you ssh tower instead of ssh 100.73.64.64) relies on the system DNS resolver being configured to use Tailscale's nameserver. On Slackware without systemd-resolved, this doesn't happen automatically, and tailscaled exits with status 2 when it tries to set it up.

Compounding this, my router's DHCP server advertises dump.town as a domain search suffix. So when I type tower, the resolver tries tower.dump.town first — which exists and resolves to the public IP of my server, not its Tailscale IP.

The short-term fix is to use the Tailscale IP directly (100.73.64.64) anywhere I need to reach tower. I also added this to /etc/dhcpcd.conf to suppress the rogue domain search from DHCP:

nooption domain_name, domain_search

Getting MagicDNS working properly on Slackware is a longer project involving either patching /etc/resolv.conf from a hook or running a local resolver like dnsmasq.

Network Filesystem via autofs

My music library lives on tower and is exported over NFS. Rather than mount it in /etc/fstab (which would block boot if the VPN wasn't up yet), I use autofs to mount it on demand.

autofs works by presenting a directory that triggers an NFS mount the first time it's accessed. If the mount fails, the directory just looks empty. This is much cleaner than retry loops or nofail in fstab.

~/.dotfiles/config/autofs/auto.master:

/mnt  /etc/auto.mnt  --timeout=300

/misc  /etc/auto.misc
/net   -hosts

~/.dotfiles/config/autofs/auto.mnt:

media  -fstype=nfs,nolock,soft,timeo=30  100.73.64.64:/mnt/user/media

This makes /mnt/media appear as a directory at all times. The NFS share from tower is mounted into it the first time something accesses the path, and unmounted after 300 seconds of inactivity.

Key NFS options:

  • nolock — required because rpc.statd is not running on this Slackware install
  • soft — NFS operations time out instead of hanging forever if the server is unreachable
  • timeo=30 — 3-second timeout (value is in tenths of a second)

Music Player Daemon

MPD runs as my user, started at boot by root, before any X session. It serves music from the NFS share over a local socket that ncmpcpp connects to.

The tricky part: MPD exits immediately if its music_directory is inaccessible. It does not retry. So the rc script needs to ensure the NFS share is actually mounted before starting MPD.

~/.dotfiles/config/mpd/rc.mpd:

#!/bin/bash

MPD=/usr/bin/mpd
CONF=/home/green/.config/mpd/mpd.conf
PIDFILE=/var/run/mpd/mpd.pid

start() {
    [ -f "$PIDFILE" ] && kill -0 "$(cat $PIDFILE)" 2>/dev/null && return

    echo "Starting mpd..."
    mkdir -p /var/run/mpd
    chown green:users /var/run/mpd

    # XDG_RUNTIME_DIR is normally created by pam_systemd on login.
    # Since we start before login, we create it manually.
    mkdir -p /run/user/1000
    chown green:users /run/user/1000
    chmod 700 /run/user/1000

    # Wait for Tailscale to have connectivity to tower
    echo "Waiting for Tailscale connectivity to tower..."
    for i in $(seq 1 30); do
        tailscale ping --c 1 100.73.64.64 &>/dev/null && break
        sleep 1
    done

    # Trigger autofs mount and wait for it to succeed
    echo "Waiting for /mnt/media/Music..."
    for i in $(seq 1 15); do
        ls /mnt/media/Music &>/dev/null && break
        sleep 1
    done

    if ! ls /mnt/media/Music &>/dev/null; then
        echo "WARNING: /mnt/media/Music not accessible, starting MPD anyway"
    fi

    sudo -u green XDG_RUNTIME_DIR=/run/user/1000 $MPD $CONF
    echo "mpd started"
}

The two-phase wait is important: first we ping tower via Tailscale (to ensure the VPN peer is reachable), then we ls the music directory (to trigger the autofs mount and confirm it succeeded). In practice both loops complete in 1-2 seconds once the network is up.

MPD's config at ~/.dotfiles/config/mpd/mpd.conf uses PulseAudio for output. Since I'm also running PipeWire with pipewire-pulse, this just works — PulseAudio clients connect to PipeWire's compatibility layer.

music_directory     "/mnt/media/Music"
playlist_directory  "/mnt/media/Music/playlists"
db_file             "~/.config/mpd/database"
pid_file            "/var/run/mpd/mpd.pid"
user                "green"
bind_to_address     "localhost"
port                "6600"

audio_output {
    type  "pulse"
    name  "PulseAudio Output"
}

X Session: Runlevel 3 + startx

I boot to runlevel 3 and log in on a TTY. X is started manually with startx, which reads ~/.dotfiles/config/xinitrc (symlinked to ~/.xinitrc):

#!/bin/sh

[ -f ~/.profile ] && . ~/.profile

# XDG_RUNTIME_DIR not set by systemd at runlevel 3
mkdir -p /run/user/$(id -u)
chmod 700 /run/user/$(id -u)

# Start PipeWire audio stack
pipewire &
pipewire-pulse &
wireplumber &

exec stumpwm

PipeWire starts here rather than at boot because it's a user service that needs XDG_RUNTIME_DIR set correctly. It runs within the X session lifetime.

I also keep KDE Plasma installed and can switch to it by changing exec stumpwm to exec startplasma-x11. Since I'm at runlevel 3, there's no display manager involved — it's just which process I exec at the end of xinitrc.

Input: libinput Touchpad

My touchpad is driven by libinput rather than the legacy Synaptics driver. libinput supports DisableWhileTyping natively via the X server, without needing a separate syndaemon process:

~/.dotfiles/config/xorg.conf.d/70-touchpad.conf:

Section "InputClass"
    Identifier "touchpad"
    MatchIsTouchpad "on"
    Driver "libinput"
    Option "DisableWhileTyping" "true"
    Option "Tapping" "on"
    Option "NaturalScrolling" "false"
EndSection

This is symlinked to /etc/X11/xorg.conf.d/70-touchpad.conf by link-root.sh. The Synaptics driver is still installed but doesn't match the touchpad because the libinput rule takes precedence (the 70- prefix puts it after the default 10- synaptics rules).

WiFi Power Management

NetworkManager's default behavior is to enable power saving on WiFi, which causes packet loss and latency spikes. One drop config file fixes it:

~/.dotfiles/config/NetworkManager/wifi-powersave-off.conf:

[connection]
wifi.powersave = 2

Value 2 means "disable power saving". This is symlinked to /etc/NetworkManager/conf.d/wifi-powersave-off.conf.

StumpWM: Window Manager Configuration

StumpWM is a tiling window manager written and configured in Common Lisp. It runs on SBCL and exposes the full language for configuration — no DSL, no limitations.

Floating Terminal Scratchpads

The most interesting thing I've set up in StumpWM is a floating scratchpad system. The goal: press a keybinding, get a floating terminal running a specific program, positioned at a fixed location on screen. Press the binding again (or focus another window), and it goes away.

The key insight was define-frame-preference with :float as the frame number:

(define-frame-preference "Default"
  (:float t t :class "alsamixer-scratch")
  (:float t t :class "ncmpcpp-scratch"))

When StumpWM adds a new window to a group, it consults frame-preference rules to decide where to place it. :float as the frame argument causes the window to be floated during group-add-window, before any tiling logic runs. This is the only reliable way to float a window from config — by the time *new-window-hook* fires, the window has already been tiled.

We use the WM_CLASS property to identify windows. wezterm --class my-class sets both fields of WM_CLASS to my-class, so the rule matches reliably.

The full scratchpad system in ~/.dotfiles/config/stumpwm/config.lisp:

(defvar *float-term-rules* '())

(defun register-float-term (class command x y width height &key persistent)
  "Register a floating terminal. Add a matching define-frame-preference rule too.
If PERSISTENT is t, smart-kill will hide the window instead of killing it."
  (setf *float-term-rules*
        (cons (list class command x y width height :persistent persistent)
              (remove class *float-term-rules* :key #'car :test #'string=))))

;; Position floats after they appear (define-frame-preference handles the float,
;; the hook handles the geometry)
(add-hook *new-window-hook*
  (lambda (win)
    (let ((rule (assoc (window-class win) *float-term-rules* :test #'string=)))
      (when rule
        (stumpwm::float-window-move-resize win
          :x (nth 2 rule) :y (nth 3 rule)
          :width (nth 4 rule) :height (nth 5 rule))))))

(defun spawn-or-focus (class command)
  "Focus existing window with CLASS, or spawn COMMAND if none exists."
  (let ((win (find-if (lambda (w) (string= (window-class w) class))
                      (screen-windows (current-screen)))))
    (cond ((null win) (run-shell-command command))
          ((eq win (current-window)) nil)
          (t (focus-window win)))))

(defcommand smart-kill () ()
  "Kill window, or hide it if it's a persistent float."
  (let* ((win (current-window))
         (class (when win (window-class win)))
         (rule (when class (assoc class *float-term-rules* :test #'string=))))
    (if (and rule (getf (nthcdr 6 rule) :persistent))
        (hide-window win)
        (kill-window-or-frame))))

;; Registrations
(register-float-term "alsamixer-scratch"
                     "wezterm start --class alsamixer-scratch -- alsamixer"
                     628 450 800 400)

(register-float-term "ncmpcpp-scratch"
                     "wezterm start --class ncmpcpp-scratch -- ncmpcpp"
                     628 300 900 500 :persistent t)

ncmpcpp is registered as :persistent t, so s-q (smart-kill) hides it rather than closing it — preserving the MPD connection. alsamixer is not persistent, so s-q just quits it. s-F (select-floating-window) lets you recover a hidden float.

Keybindings

StumpWM supports both a prefix-key map (C-t by default) and direct top-level bindings via *top-map*. I prefer Super-key bindings for common actions:

Binding Action
s-d rofi run launcher
s-w rofi window picker
s-hjkl focus frame (vim directions)
s-q smart-kill
s-f toggle fullscreen
s-F select floating window
s-1..4 switch group
s-- vertical split
s-\ horizontal split
s-c a alsamixer float
s-m p ncmpcpp float
s-m v pick video with mpv
s-m d download URL from clipboard

Brightness is via a sub-map: s-c b 1 through s-c b 0 set 10%-100%.

Gaps

swm-gaps module provides inner and outer gap support. I have keybindings to switch between gap presets (s-W g 0/1/2/3 for none/small/medium/large).

Dotfiles Management

All of the above is stored in ~/.dotfiles and managed by two scripts:

  • link.sh — user-level symlinks (run as self)
  • link-root.sh — system-level symlinks (run as root)

Both scripts use a backup_and_link function that moves any existing file to .bak before creating the symlink, so they're safe to run on a system that already has config files in place.

link-root.sh also idempotently appends service invocations to /etc/rc.local if they're not already present, and chmod +x's the rc scripts it symlinks.

The advantage of this approach over Nix or stow: it's plain shell, readable, debuggable, and doesn't require any tooling to be installed first. The disadvantage: no atomicity, no rollback. For a single personal machine that's a fine tradeoff.