A Linux Thin Client


We want an inexpensive but highly configurable, reliable, and easily maintained box that acts primarily as a thin client. That is, it primarily functions as an RDP client for a terminal server. A desirable secondary function, if possible, would be for that client to also run a modern, full-featured web browser.

After testing several devices within our budget, we opted for a Celeron Intel NUC with 4 GB RAM and a small SSD. This (not really thin) device offers more flexibility and performance than a traditional Wyse or HP thin client. Using Linux removes licensing costs as a concern. Ansible makes fleet management and maintenance relatively painless.

Design considerations and user experience

Implementation overview


The kickstart/preseed story for Debian-based distributions is challenging in two ways:

Administration, maintenance, and remote support

The initial configuration happens by a preseed file at install, with the remainder happening by Ansible playbook. However, the rest of this document highlights some parts of that configuration.

It’s handy sometimes to have the user easily see the IP address of their computer. Use Conky to show it on their desktop.

#  apt install conky
$  mkdir /home/htuser/.config/autostart
$  vi /home/htuser/.config/autostart/conky.desktop
$  cat /home/htuser/.config/autostart/conky.desktop
[Desktop Entry]
Exec=conky --daemonize --pause=20
$  vi .conkyrc
$  cat .conkyrc
        background = false,
        double_buffer = true,
        gap_y = 20,
        gap_x = 90,
        own_window = true,
        own_window_transparent = true,
        own_window_argb_visual = true,
        own_window_type = "desktop",
        own_window_hints = "undecorated, below, sticky, skip_taskbar, skip_pager",
        total_run_times = 0,
        update_interval = 120,
        use_xft = true,
conky.text = [[
My IP address is:    ${exec ip ro get | head -1 | awk '{ print $7 }'}

Running as an unprivileged user and making auto-login work

Ubuntu desktop seems to assume in several respects that the user will be a sudoer, or that they’ll at least know their password.

#  apt purge unattended-upgrades
#  vi /etc/gdm3/custom.conf
$  gsettings set org.gnome.desktop.session idle-delay 0
$  gsettings set org.gnome.desktop.screensaver lock-enabled false
$  gsettings set org.gnome.desktop.lockdown disable-lock-screen true


We have a couple secrets on the client, like the Hello API key and the VNC password, that we’d like to keep secret. Our adversary is some kid who steals the box, not the NSA.

  1. Encrypt swap.
  2. Encrypt any scripts containing secrets (gpg symmetric).
  3. On boot, create a small tmpfs.
  4. Curl the decryption key from the network.
  5. Decrypt the secret files into the tmpfs.

Encrypt swap:

#  apt install cryptsetup
#  fallocate -l 4G /cryptswap
#  vi /etc/crypttab
  +  cryptswap /cryptswap /dev/urandom swap
#  cryptdisks_start cryptswap
#  swapoff -va
#  vi /etc/fstab
  -  UUID=a9c3c70b-64de-40bc-a42d-335afc0bc1b6 none swap sw 0 0
  +  /dev/mapper/cryptswap none swap sw 0 0
#  swapon -va

At boot, after the network comes up, we run a script like:

set -euf
mkdir -p /root/tmp
mount -t tmpfs -o size=10M tmpfs /root/tmp
curl > /root/tmp/passphrase
# Maybe we need to de-obfuscate the passphrase here — base64 decode or whatever.
gpg --batch --passphrase-file /root/tmp/passphrase --output /root/tmp/myscript --decrypt myscript.asc

This assumes we previously encrypted our secret files like:

$ gpg -ac myscript


We want a simple, automatically updating way to generate an Ansible inventory. Use Have the client push their info to the API server, and grab a fresh inventory file whenever we need to run a playbook.


Normally, we’d use iptables or nftables directly, but ufw is the default Ubuntu firewall, and it’s a useful wrapper to ease the transition for whatever becomes the standard Linux firewall in the future.

#  systemctl enable ufw
#  ufw enable
#  ufw default deny incoming
#  ufw default allow outgoing
#  ufw allow from to any app OpenSSH
#  ufw allow from to any port 5900
#  ufw reload
#  ufw status verbose
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
New profiles: skip

To                         Action      From
--                         ------      ----
22/tcp (OpenSSH)           ALLOW IN
5900                       ALLOW IN