478 lines
16 KiB
Org Mode
478 lines
16 KiB
Org Mode
#+title: Boot Setup & Low-Level Configuration on Slackware
|
|
#+author: Ian Keane
|
|
#+date: 2026-08-08
|
|
#+options: toc:t num:nil
|
|
|
|
* 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:
|
|
|
|
#+begin_src bash
|
|
#!/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
|
|
#+end_src
|
|
|
|
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 [[https://github.com/jtroo/kanata][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=:
|
|
|
|
#+begin_src bash
|
|
#!/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
|
|
#+end_src
|
|
|
|
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
|
|
|
|
[[https://tailscale.com][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=:
|
|
|
|
#+begin_src bash
|
|
#!/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
|
|
#+end_src
|
|
|
|
** 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:
|
|
|
|
#+begin_src conf
|
|
nooption domain_name, domain_search
|
|
#+end_src
|
|
|
|
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
|
|
[[https://www.kernel.org/doc/Documentation/filesystems/autofs.txt][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=:
|
|
|
|
#+begin_src conf
|
|
/mnt /etc/auto.mnt --timeout=300
|
|
|
|
/misc /etc/auto.misc
|
|
/net -hosts
|
|
#+end_src
|
|
|
|
=~/.dotfiles/config/autofs/auto.mnt=:
|
|
|
|
#+begin_src conf
|
|
media -fstype=nfs,nolock,soft,timeo=30 100.73.64.64:/mnt/user/media
|
|
#+end_src
|
|
|
|
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=:
|
|
|
|
#+begin_src bash
|
|
#!/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"
|
|
}
|
|
#+end_src
|
|
|
|
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.
|
|
|
|
#+begin_src conf
|
|
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"
|
|
}
|
|
#+end_src
|
|
|
|
* 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=):
|
|
|
|
#+begin_src bash
|
|
#!/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
|
|
#+end_src
|
|
|
|
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=:
|
|
|
|
#+begin_src conf
|
|
Section "InputClass"
|
|
Identifier "touchpad"
|
|
MatchIsTouchpad "on"
|
|
Driver "libinput"
|
|
Option "DisableWhileTyping" "true"
|
|
Option "Tapping" "on"
|
|
Option "NaturalScrolling" "false"
|
|
EndSection
|
|
#+end_src
|
|
|
|
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=:
|
|
|
|
#+begin_src conf
|
|
[connection]
|
|
wifi.powersave = 2
|
|
#+end_src
|
|
|
|
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:
|
|
|
|
#+begin_src lisp
|
|
(define-frame-preference "Default"
|
|
(:float t t :class "alsamixer-scratch")
|
|
(:float t t :class "ncmpcpp-scratch"))
|
|
#+end_src
|
|
|
|
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=:
|
|
|
|
#+begin_src 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)
|
|
#+end_src
|
|
|
|
=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.
|