Monday, August 17, 2026

Standard

I run Ubuntu 24.04 with ZFS on my ThinkPad X1 Carbon, and I have an encrypted ZFS dataset for my workspace. By default, ZFS prompts for a passphrase at every login — annoying, but secure. This post explains how I wired it up to the TPM2 chip so the dataset unlocks automatically at boot, without storing the passphrase anywhere on disk.

The idea

Modern laptops have a TPM (Trusted Platform Module) chip that can seal a secret against the current state of the boot chain. The secret can only be unsealed if the firmware, bootloader, and Secure Boot state are identical to when it was sealed. If someone tampers with the boot chain — or steals just the drive — the TPM refuses to hand over the secret.

PCRs (Platform Configuration Registers) are the values the TPM measures during boot. I bind to:

  • PCR 0 — UEFI firmware
  • PCR 2 — Option ROMs
  • PCR 4 — Bootloader (GRUB)
  • PCR 7 — Secure Boot policy and signing certificates

With UEFI Secure Boot enabled, PCR 7 is particularly strong — booting a live USB or disabling Secure Boot changes PCR 7 and makes unseal impossible.

Prerequisites

  • Ubuntu 24.04 with ZFS
  • An encrypted ZFS dataset (mine is rpool/USERDATA/workspace)
  • TPM2 chip (/dev/tpm0)
  • UEFI Secure Boot enabled
  • tpm2-tools installed: sudo apt install tpm2-tools
  • Your user in the tss group: sudo usermod -aG tss $USER

Step 1 — Find a free TPM persistent handle

tpm2_getcap handles-persistent

Pick a handle in the 0x81000000–0x8100FFFF range that isn't listed. I used 0x81000002.

Step 2 — Seal the passphrase

I wrote a script that reads the passphrase interactively (no echo, confirmation prompt), writes it only to /tmp (tmpfs = RAM, never hits disk), seals it into the TPM, then shreds the temp file immediately.

#!/usr/bin/env bash
set -euo pipefail

HANDLE="${1:-0x81000002}"
WORKDIR="$(mktemp -d)"
trap 'rm -rf "$WORKDIR"' EXIT

IFS= read -rs -p "Enter ZFS passphrase: " PASSPHRASE; echo ""
IFS= read -rs -p "Confirm passphrase:   " PASSPHRASE2; echo ""

[[ "$PASSPHRASE" == "$PASSPHRASE2" ]] || { echo "Mismatch" >&2; exit 1; }

tpm2_createpolicy --policy-pcr -l sha256:0,2,4,7 -L "$WORKDIR/policy.digest"
tpm2_createprimary -C e -g sha256 -G rsa -c "$WORKDIR/primary.ctx" >/dev/null

printf '%s' "$PASSPHRASE" > "$WORKDIR/secret"
tpm2_create -g sha256 -u "$WORKDIR/obj.pub" -r "$WORKDIR/obj.priv" \
    -C "$WORKDIR/primary.ctx" -L "$WORKDIR/policy.digest" \
    -i "$WORKDIR/secret" >/dev/null
shred -u "$WORKDIR/secret"
unset PASSPHRASE PASSPHRASE2

tpm2_load -C "$WORKDIR/primary.ctx" -u "$WORKDIR/obj.pub" \
    -r "$WORKDIR/obj.priv" -c "$WORKDIR/seal.ctx"
sudo tpm2_evictcontrol -C o -c "$WORKDIR/seal.ctx" "$HANDLE"

echo "Sealed at $HANDLE"

Save it, chmod 700, and run it. You'll be prompted for your ZFS passphrase and then your sudo password for the final persist step.

Verify it worked:

tpm2_getcap handles-persistent        # should now include 0x81000002
tpm2_unseal -c 0x81000002 -p pcr:sha256:0,2,4,7   # should print your passphrase

Step 3 — Auto-unlock at boot via systemd

Ubuntu's ZFS tooling auto-generates a systemd unit that prompts for the passphrase. We override it with a drop-in that tries the TPM first and falls back to a password prompt if unseal fails (e.g. after a firmware update).

sudo mkdir -p /etc/systemd/system/zfs-load-key-rpool-USERDATA-workspace.service.d

sudo tee /etc/systemd/system/zfs-load-key-rpool-USERDATA-workspace.service.d/tpm-unseal.conf <<'EOF'
[Service]
ExecStart=
ExecStart=/bin/sh -c '\
  set -eu; \
  keystatus="$(/sbin/zfs get -H -o value keystatus "rpool/USERDATA/workspace")"; \
  [ "$keystatus" = "unavailable" ] || exit 0; \
  if tpm2_unseal -c 0x81000002 -p pcr:sha256:0,2,4,7 2>/dev/null \
     | /sbin/zfs load-key "rpool/USERDATA/workspace"; then \
    exit 0; \
  fi; \
  echo "TPM unseal failed, falling back to passphrase prompt" >&2; \
  count=0; \
  while [ $count -lt 3 ]; do \
    systemd-ask-password --id="zfs:rpool/USERDATA/workspace" \
      "Enter passphrase for rpool/USERDATA/workspace:" \
      | /sbin/zfs load-key "rpool/USERDATA/workspace" && exit 0; \
    count=$((count + 1)); \
  done; \
  exit 1'
StandardError=journal
EOF

sudo systemctl daemon-reload

Step 4 — Test without rebooting

sudo zfs unload-key rpool/USERDATA/workspace
sudo systemctl restart zfs-load-key-rpool-USERDATA-workspace.service
zfs get keystatus rpool/USERDATA/workspace

If keystatus shows available with no password prompt — it works.

What happens after a kernel or firmware update?

PCR 4 changes when GRUB is updated. The TPM unseal will fail, and the fallback password prompt kicks in. After entering your passphrase manually, just re-run the seal script to update the sealed blob against the new PCR values.

Security summary

ThreatProtected?
Drive stolen, put in another machine✅ Yes — wrong TPM
Boot from live USB✅ Yes — PCR 7 changes (Secure Boot)
Tampered bootloader✅ Yes — PCR 4 changes
Disabled Secure Boot in UEFI✅ Yes — PCR 7 changes
Normal boot, attacker has root access❌ No — they can run tpm2_unseal too

For a laptop used on the go, this covers the realistic threat: theft of the device while powered off. The data is inaccessible without the original, unmodified hardware and boot chain.

Saturday, June 27, 2026

FreeCLI - LLM agnostic ncurses based CLI

Standard

FreeCLI — A Terminal Chat UI for All the LLMs, Written in C

I've been building a small open-source project over the past few weeks that scratched a personal itch: I wanted a fast, keyboard-driven terminal UI for chatting with large language models — one that wasn't locked to a single provider and didn't pull in a Python runtime or an Electron app just to show me a text box.

The result is FreeCLI, and it's now on GitHub under the GPL-3 license.

→ github.com/NikolajHansen/FreeCLI

What it is

 

FreeCLI is a three-pane ncurses application written in C. On the left you have a list of persistent chat sessions (auto-named by topic — so instead of "Chat 1" you get something like "#1 API Design"). In the middle is the scrollable conversation. On the right is a live view of background workers — because one of the more interesting features is that models can call other models mid-conversation.

It ships with four LLM providers out of the box:

  • xAI — Grok-3-fast, Grok-3, Grok-3-mini
  • Anthropic — Claude Opus, Sonnet, Haiku (3.5 and 4.5 families)
  • Google — Gemini 2.5 Flash/Pro, 2.0 Flash, 1.5 family
  • IBM watsonx — Granite 3, Llama 3.3/3.1 70B

You switch between them with /provider and /model — both pop up a small overlay right in the terminal.

The part I'm most proud of: LLM-to-LLM communication

The backend runs as a separate process and communicates with the TUI over a local TCP socket. When a model supports tools (currently xAI), it gets an ask_ai function injected into its context. It can then call that tool with a question and a target model name — say, asking Claude to review a piece of code while Grok is working through the broader problem.

Those sub-queries show up as indented entries in the workers pane with their own spinner, so you can watch the inter-AI conversation happen in real time. The sub-queries themselves have tools disabled to prevent infinite recursion.

Architecture

The split between the TUI binary (freecli) and the backend binary (freecli-backend) was a deliberate choice: the TUI has zero dependency on libcurl. It just renders, handles input, and talks IPC. The backend owns all the HTTP, JSON parsing, and provider logic. This keeps the ncurses side lean and means you could theoretically swap the backend for something else entirely.

The IPC wire format is simple: a 4-byte length prefix, a 4-byte message type, an 8-byte correlation ID, then the payload. Requests carry the session index, message history, model name, and provider ID. Replies come back as streamed tokens or structured events (worker start/end, session rename, etc.).

Session naming

After your first exchange in a new session, the backend silently fires off a one-shot call to a fast model with a prompt asking for a short topic label. The result comes back over IPC and replaces "Chat 1" with something descriptive. If the name is too long to fit in the sidebar, the selected entry scrolls like a marquee. Small detail, but it makes navigating a long list of sessions much more pleasant.

Building it

It uses autotools, which I know is not fashionable, but it handles the Linux/macOS/FreeBSD portability story well and adds zero runtime dependencies beyond what you already have on any Unix system.

 git clone https://github.com/NikolajHansen/FreeCLI
 cd FreeCLI
 ./bootstrap
 ./configure
 make -j4
 src/freecli
 

You'll need ncurses, libcurl, and a C compiler. On Debian/Ubuntu: apt install libncurses-dev libcurl4-openssl-dev. On FreeBSD: pkg install ncurses curl.

What's next

A few things on the list: streaming token output (so replies appear word by word rather than all at once), persisting the selected provider and model to a config file, a /delete command for sessions, and tool support for Anthropic (their tool format differs from OpenAI's so it needs its own implementation). Adding a new provider is fairly mechanical — one .c file and two registry entries — so if there's demand for OpenAI, Mistral, or Ollama support it shouldn't take long.

If you try it, open an issue or send a PR. Code is on GitHub, GPL-3.

https://github.com/NikolajHansen/FreeCLI

Sunday, March 30, 2025

Rackmount hardware of Rasberry PI4

Standard

Background.


After having had my Raspberry PIs in a box together with a lot of wiring spaghetti, its nice to finally have some decent rack mount enclosures to tidy up stuff.

This will be the first of two enclosures for a total of 10 nodes. I have not really decided if they are all going to be in the same cluster or two individual.

Hardware.

The nodes themselves are Raspberry PI4-B with 8 GB of memory. They each have 128 GB of internal storage via the onboard Micro SD card. I use some SANDISK high endurance cards, that are made for 24/7 operation and rated for 20.000 hours.

Between the nodes is a normal gigabit network switch with 10 gigabit fiber backbone. for now I have more than ample ports in one switch.

 
The enclosure model is the Rackmount RM-PI-T1. It is a 1U enclosure with room to add a fan or a HAT for the PIs if necessary.
 
If your rack cabinet already has fans I doubt it will be necessary.

Software.

The nodes run normal PI-OS which is a Debian derived Linux based OS specially tailored for the PI hardware. I use the lite version which is a headless version of the OS.

On this I use the K3S (Rancher) lightweight Kubernetes platform. I think some of the choices Canonical have made lately makes me doubt MicroK8s in a production environment and I think full on Kubeadm (Vanilla Kubernetes) is probably overkill for my needs.

I use no kind of hardware emulation. This is what is known as "bare metal" in Kubernetes terms. You could add this as an extra layer of abstraction. As it is here the images need to be built for the ARM architecture - and not for AMD64 which most ready made ones are. 

Something that has surprised a lot of developers after they got a new Macbook I think :-P.

You could add a hw virtualization layer - but I think the performance impact would be severe. 

Some of the nodes may convert to FreeBSD at a later stage. Mostly as an experiment. They will then not be part of the Kubernetes cluster but rather their own kind running the needed services via Jails on the physical host.

Images.

The Kubernetes images are built though Ainsible. The build environment hosted on Github and built via Github actions.

I plan to make a seperate more in depth post about this and also publish scripts and configs publicly on Github (once they fully do what I want).

Backend server.

For this purpose I currently have an older and much more powerful HP DL-585 FreeBSD server with storage, that attaches to the nodes for persistent storage. Also it currently runs any databases needed.

On this server each logical node is hosted in a Jail, that gives it its own IP stack and own ZFS pool area in the storage area. 

The total ZFS pool size is currently 14 TB.

This has been the way the services now in the Kubernetes cluster where all hosted in my old setup before the PI nodes.

Conclusion.

These small machines are remarkably capable and have a very low power consumption footprint. The new PI5 is arguably even more desirable with its built in interface to real SSD storage.

However the idea behind Kubernetes is to have the nodes stateless and that is negated if the different nodes have persistent data, that is not mirrored to all of the nodes.

If you have your own business and require flexible computing I highly recommend the approach of using cheaper HW like this, because you will be able to size your system in a way you can have multiple nodes fail and not even know about it except for the alarm the system will give you.

It is then very easy to simply replace a faulty node with new hardware, add the needed disk image and let it rejoin the cluster. 
 
And do not be alarmed at the prospects of having your own on site Kubernetes setup. If I can do it with of the self parts for less than 7500 DKK any company with a decent IT department can also run something similar. 
 
It is the most obvious way of complying to any GDPR legislation in the EU, and avoiding any worries how our US "ally" treat our data. Which you will have to if you use hosting with the hosting titans.

Tuesday, April 6, 2021

libvirt-dnsmasqd running as part of libvirt

Standard

Open DNS server on external IP

Background

When you run libvirt / KVM on Ubuntu it seems the framework starts up a second local DNS server beside systemd-resolved. Since only one server can occupy localhost port 53 (systemd-resolved) libvirt-dnsmasq uses HOSTNAME / Port 53. So that will be whatever IP the DHCP server assigns to your PC. 

This IP is accessible from all other sources on the network as a matter of fact and running an open DNS server on it is probably not a good idea.

Solution

Check the virtual net status:

# virsh net-list
 Name                 State      Autostart     Persistent
----------------------------------------------------------
 default              active     yes           yes

Check to see if libvirt-dnsmasq is running

# lsof -i TCP:53

Remove the default virtual network for your virtual hosts.

# virsh net-destroy default

Prevent it from autostarting at all

# virsh net-autostart --network default --disable

If you need it again start it with

# virsh net-start default

Result

libvirt-dnsmasq is no longer listening on port 53 / DNS on whatever IP your interface(s) have been given on your network.

# lsof -i TCP:53
COMMAND   PID            USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
systemd-r 635 systemd-resolve   13u  IPv4  25136      0t0  TCP localhost:domain (LISTEN)

# virsh net-list
 Name   State   Autostart   Persistent
----------------------------------------





Wednesday, January 17, 2018

Citrix on linux

Standard

How to get the black address bar

1. Use Firefox. It works way better with the ICACLient than chromium. I have not tested chrome, buit I suspect it is the same.

1. Setup of local ICAClient

- Navigate to the root of the ICA installation. For me /opt/Citrix/ICACLient.
- Find the file All_Regions.ini 
- Under [Client Engine\GUI]
- Add ConnectionBar=* 

Restart your ICAClient and log on again. It should now be visible in the top of the screen.

Saturday, September 2, 2017

FreeBSD 11 - vt console debacle

Standard

Vt console in FreeBSD

Introduction

For some reason server distributions of *nix now have to have a vt based (compositing) console, so that you can get your console into the larger resolutions.

Personally I fail to see the need for this at all. When you connect to your server it is via ssh 95% of the time. Or probably more. And your client PC where off you run ssh probably has a hd display today. Using the actual console is something you do when there are problems. Usually in single user mode. This does not have to be bling bling.

What about us, that depend on the serial console? Or text mode without a compositor. Well it would seem FreeBSD is now going down the Ubuntu server road, and we have to do some fiddeling in order to make our Inside Lights out cards, serial consoles and what not working again.

At least for me, my iLO card on my HP server could no longer bridge to the console after an upgrade to FreeBSD 11. I was told the console was in an unsupported graphics mode and after plugging a monitor onto the server, it was in 640x480.

How to fix this.

Fortunately this is simple not requiring a custom kernel build or anything. In loader.conf put:

kern.vty=sc

And you are back to the old sc driver for the console. And I can again get a console via ethernet. Also in single user via the iLO.

Conclusion

Back to normal ..

Tuesday, July 25, 2017

HP inside lights out and OpenSSH

Standard

Problem

The sshd server implementation made by HP on ILO cards - especially older ones - can be notoriously difficult to use with newer versions of OpenSSH.

The main reason is algorithms rendered unsafe by old age combined with a very strange implementation by HPs hands.

Solution

The following configuration on the client side made my current version of OpenSSH play reasonably nice with ILO / SSH. There is some unexplained disconnects by the server I cannot figure out. 

In .ssh/config

Host <hostname>/<IP>
PasswordAuthentication yes
ChallengeResponseAuthentication no
GSSAPIAuthentication no
HostbasedAuthentication no
PubkeyAuthentication no
RSAAuthentication no
Compression no
ForwardAgent no
ForwardX11 no
Ciphers aes128-cbc,3des-cbc,blowfish-cbc,cast128-cbc,arcfour128
HostKeyAlgorithms ssh-rsa,ssh-dss
KexAlgorithms diffie-hellman-group1-sha1
MACs hmac-sha1
ServerAliveInterval 0

Result

You can now SSH to the ILO card and use the cmd line tool from HP. Issue a 

</>hpiLO-> remcons

To get a real HW console bridged over the network.