A how-to guide for offline YubiKey one-time-password (OTP) logins on Radiator RADIUS, with the YubiKeys kept in a local SQLite database or a shared PostgreSQL database, managed with the scripts in this repository. It covers a lab you can build in a container, a server installation, adding accounts, checking credentials, day-to-day maintenance, moving from SQLite to PostgreSQL, a second PostgreSQL server that takes over when the first fails, three sites with automatic failover, logins at a site that is cut off, one anycast address for all of them, and a tool that sets up all of that from one input file.
The scripts, this guide and the releases are on GitHub, under the MIT license: https://github.com/robert-patrick-texas/radiator-yubikey.
Every procedure in this guide was run as written with
Radiator 4.30 on Ubuntu 26.04 LTS with
PostgreSQL 18 (and Ubuntu's Patroni 4.1 and etcd 3.5
for three sites, and FRR 10.5 for anycast), and
tests/radiator/run.sh repeats the checks (see Testing).
How it works
Each time someone touches a YubiKey, it types a 44-character OTP. The first 12 characters are the YubiKey's public ID. The other 32 are a token encrypted with an AES key stored in the YubiKey. The token holds the YubiKey's private ID and two counters that go up with every OTP.
Radiator's <AuthBy SQLYUBIKEY> checks a login like
this:
- It splits what the user typed into the static password (PIN), the
public ID and the token. Both
1234:vvccccbbcdef...and1234vvccccbbcdef...work. - It looks up the row for that username and public ID in the database.
- It decrypts the token with the AES key from that row and checks its CRC.
- It checks that the private ID in the token matches the row
(
CheckSecretId). - It rejects the OTP unless its counters are ahead of the ones saved in the row, so a captured OTP can't be used twice, and then saves the new counters. The database refuses to save counters that don't move forward, so even the same OTP sent to two Radiator servers at the same moment is accepted only once.
Nothing is sent to Yubico. The catch is that Radiator must know each YubiKey's AES key, and Yubico keeps the factory key secret, so every YubiKey is reprogrammed with an AES key you generate, and that key is loaded into the database.
The scripts
| File | What it does |
|---|---|
yubikey-db.sh |
Creates the database, adds, updates, labels, lists, disables and
deletes users and YubiKeys, checks a password and OTP
(verify), exports and restores, manages service accounts
and groups, and prints the Radiator configuration. SQLite by default,
PostgreSQL with -b pg. |
install.sh |
Installs the radiator-yubikey package, which puts all
of these in /opt/radiator-yubikey, with the packages that
it and Radiator's YubiKey support need, after checking that Radiator is
installed (see The
radiator-yubikey package). |
yubikey-db-migrate.sh |
Copies every YubiKey from the SQLite database into PostgreSQL, verified row by row, and keeps counters in step during a cutover. |
yubikey-db-user.example.sh |
An example wrapper that adds or updates one test account from variables at the top of the file. |
radiator.example.conf |
A Radiator configuration for YubiKey logins, to replace the package's sample. |
radiator-groups.example.conf,
dictionary.yubikey |
A Radiator configuration for RADIUS and TACACS+ logins, by YubiKey or service account, checked against groups (see Groups and TACACS+), and the dictionary entry it needs. |
yubikey-partition.sh |
Keeps a copy of the keys on each server of the three sites, and
makes Radiator use it while the server can reach no other site (see Partition mode); with
yubikey-partition.service and
yubikey-partition.example.conf. |
yubikey-anycast.sh |
Puts the anycast addresses on the loopback interface, for FRR to
advertise, only while the local Radiator accepts logins (see Anycast); with yubikey-anycast.service
and yubikey-anycast.example.conf. |
yubikey-backup.sh |
Backs up the PostgreSQL database every day, encrypted for a
certificate whose key is kept elsewhere, checks and decrypts the
backups, and restores one (see Automatic
backups); with yubikey-backup.service,
yubikey-backup.timer and
yubikey-backup.example.conf. |
yubikey-turnkey, yubikey-turnkey-setup.sh,
site.example.toml |
Make and run the setup of three sites from one input file (see Turn-key setup). |
users.example.csv |
A sample bulk-import file (test values only). |
VERSION |
The scripts' version: each script reads it from this file, next to
it, and shows it with -V. |
tests/make-otp.sh |
Makes the OTP a YubiKey with given values would type, for tests without a YubiKey. |
tests/radiator/ |
Builds a container with Radiator and runs real logins against both databases. |
packaging/ |
Builds the radiator-yubikey package
(packaging/build-deb.sh). |
SQLite or PostgreSQL?
| SQLite | PostgreSQL | |
|---|---|---|
| Where the keys are | One file on the Radiator server | A database that several Radiator servers can share |
| Several Radiator servers | Each has its own file and its own counters, so an OTP used on one server is still accepted on another | One set of counters, so an OTP works only once anywhere |
| Who can change what | Anyone who can write the file | Radiator's login can read keys and update only counters; admins are members of an admin role |
| To run | Nothing more | A PostgreSQL 18 server, on the Radiator server or elsewhere (over TLS) |
| If the database server fails | — | A second server can take over (see Two database servers), or with three sites, Patroni moves the primary by itself (see Three sites) |
Start with SQLite for one Radiator server. Use PostgreSQL when more than one Radiator server checks the same YubiKeys. You can move later (see Move from SQLite to PostgreSQL).
What you need
- Radiator from Radiator Software: their packages for
Ubuntu 26.04, or their source. This guide uses the packages
radiator_4.30-1_all.debandradiator-radius-utilxs_2.4-4.resolute_amd64.deb. Theradiatorpackage needs no license file; aradiator-lockedpackage needs/etc/radiator/license.conffrom Radiator Software. - The
radiator-yubikeypackage andinstall.sh, from this repository's releases. - Ubuntu 26.04 LTS for the Radiator server and, for PostgreSQL, a PostgreSQL 18 server (Ubuntu 26.04's default).
- YubiKeys with a free OTP slot, and YubiKey
Manager (
ykman) on a trusted workstation to program them. - For the lab and the automated test: Docker, usable
without
sudo(membership of thedockergroup).
| Machine | Packages |
|---|---|
| Radiator server | Radiator; libauth-yubikey-decrypter-perl,
libcrypt-rijndael-perl,
libcache-fastmmap-perl, libdbd-sqlite3-perl +
sqlite3 (SQLite) and libdbd-pg-perl +
postgresql-client (PostgreSQL), which
install.sh installs |
| Where you run the scripts (usually the Radiator server) | The radiator-yubikey package (with perl,
and sqlite3 or postgresql-client);
libcrypt-rijndael-perl for verify |
| PostgreSQL server (if separate) | postgresql |
| Each server, for partition mode | sqlite3, libdbd-sqlite3-perl |
| Each server, for anycast | frr |
| Workstation that programs keys | ykman |
Try it in a lab container
A lab shows the whole setup working before you touch a server, and
needs no YubiKey: tests/make-otp.sh makes OTPs for test
keys. The container is Ubuntu 26.04 with Radiator, SQLite and PostgreSQL
18 (and Patroni and etcd, which only the three-site test uses).
Build the lab image
Put the two Radiator packages in a directory of their own, then build
the image from the repository's
tests/radiator/Dockerfile:
git clone https://github.com/robert-patrick-texas/radiator-yubikey.git
cd radiator-yubikey
mkdir -p ~/radiator-packages # copy radiator_*.deb and radiator-radius-utilxs_*.deb here
docker build -t radiator-yubikey-lab -f tests/radiator/Dockerfile ~/radiator-packages
Start a container with the repository mounted read-only at
/opt/radiator-yubikey, where the package puts the scripts
on a server. You work as root inside it, so the commands below have no
sudo:
docker run -it --rm --name yk-lab -v "$PWD:/opt/radiator-yubikey:ro" -w /opt/radiator-yubikey radiator-yubikey-lab bash
SQLite in the lab
Create the database, add a test user, and install the Radiator configuration (sections 6 to 8 explain each step):
./yubikey-db.sh init --owner radiator:radiator
./yubikey-db.sh add -u alice -P vvccccbbcdef -p 4a6f8e2d1c3b -k 6b1f0a9d2c7e4f3a8b5d0c1e9f2a7b6d -w 1234
./yubikey-db.sh config > /etc/radiator/conf.d/yubikey.conf
install -m 0640 -o root -g radiator radiator.example.conf /etc/radiator/radiator.conf
Start Radiator as the radiator user, the way its systemd
unit does:
sudo -u radiator /opt/radiator/radiator/radiusd -foreground -no_pid_file -config_file /etc/radiator/radiator.conf &
sleep 2
Log in with an OTP for alice's test key (counter 1), then try the same OTP again:
OTP=$(tests/make-otp.sh vvccccbbcdef 4a6f8e2d1c3b 6b1f0a9d2c7e4f3a8b5d0c1e9f2a7b6d 1)
/opt/radiator/radiator/radpwtst -s 127.0.0.1 -secret local-test-secret -auth_port 1812 -noacct -user alice -password "1234:$OTP"
/opt/radiator/radiator/radpwtst -s 127.0.0.1 -secret local-test-secret -auth_port 1812 -noacct -user alice -password "1234:$OTP"
tail -2 /var/log/radiator/authentication.log
The first login prints OK; the second prints
Rejected: Request Denied, and the log gives the reason,
Replay attack detected. Check the next OTP without
Radiator, and look at the saved counters:
printf '%s\n' 1234 "$(tests/make-otp.sh vvccccbbcdef 4a6f8e2d1c3b 6b1f0a9d2c7e4f3a8b5d0c1e9f2a7b6d 2)" | ./yubikey-db.sh verify -u alice
./yubikey-db.sh list
PostgreSQL in the lab
Start PostgreSQL, create its roles and database (root is the lab's admin), copy alice over from SQLite, and point Radiator at PostgreSQL:
pg_ctlcluster 18 main start
sudo -u postgres ./yubikey-db.sh -b pg init-pg --admin root
./yubikey-db-migrate.sh /etc/radiator/db/yubicred.db
./yubikey-db.sh -b pg config > /etc/radiator/conf.d/yubikey.conf
pkill -f radiusd; sleep 1
sudo -u radiator /opt/radiator/radiator/radiusd -foreground -no_pid_file -config_file /etc/radiator/radiator.conf &
sleep 2
The counters came along, so the OTP that verify used
(counter 2) is rejected, and the next one is accepted:
for n in 2 3; do
/opt/radiator/radiator/radpwtst -s 127.0.0.1 -secret local-test-secret -auth_port 1812 -noacct -user alice \
-password "1234:$(tests/make-otp.sh vvccccbbcdef 4a6f8e2d1c3b 6b1f0a9d2c7e4f3a8b5d0c1e9f2a7b6d $n)"
done
./yubikey-db.sh -b pg list
With a real YubiKey
Program a spare YubiKey as in Program the YubiKeys, add it (the script asks for the private ID, AES key and PIN), then touch the YubiKey when asked:
./yubikey-db.sh -b pg add -u you -P <public ID from ykman>
./yubikey-db.sh -b pg verify -u you
For a login through Radiator, read the OTP into a variable first, because the YubiKey presses Enter after the OTP:
read -rsp 'PIN: ' PIN; echo; read -rp 'Touch the YubiKey: ' OTP
/opt/radiator/radiator/radpwtst -s 127.0.0.1 -secret local-test-secret -auth_port 1812 -noacct -user you -password "$PIN:$OTP"
Leave the container with exit; --rm deletes
it.
The automated test
tests/radiator/run.sh builds the same image, with
Patroni, etcd and FRR (and debhelper and lintian, to build and check the
package) added, and runs seven tests of real Radiator logins:
e2e.sh, 145 checks in a throwaway container: SQLite with and withoutRequire2Factor, PostgreSQL over the local socket and over TLS, the example wrapper, a user with two YubiKeys (each with its own counters, PIN andactive; another user's YubiKey refused), a cutover from SQLite to PostgreSQL while Radiator runs, the same OTP sent twice at the same moment to two of Radiator's farm processes (accepted twice without the trigger, once with it), and a counter save on PostgreSQL whose answer is lost (its session ended while it waits for a standby: not answered with 1.8.1'sUpdateQuery, accepted with this one's), and a YubiKey stored for the usernameDEFAULT, which logs in no one else, and the anycast check renewing its key with Radiator's login over TLS, then logging in with the new key (Radiator refuses the old one). For each login it checks Radiator's answer and the reason it logs, and thatverifyagrees.groups.sh, 133 checks in a throwaway container, withradiator-groups.example.confand devices at addresses in 127.0.0.0/8: logins over TACACS+ (with a small client on Radiator's own modules, and with Radiator'stacacsplustest) and RADIUS, by YubiKey and by service account, from devices in three device groups and from an unlisted one, on SQLite and then PostgreSQL. It checks each login, privilege level, command authorization and RADIUS reply against the groups; a login followed by eight authorizations at once, which several of Radiator's processes answer;enable; accounting; that a change in the groups, or a disabled account, applies to a session already open; and that nothing is allowed while PostgreSQL is stopped. It also runs the anycast check'scheckover RADIUS and TACACS+, set up as in Set up anycast on each server, step 5, with its service account disabled, its group's access taken away, another password in the database, a wrong key, a wrong port and an address Radiator doesn't listen on; and that its accounts can't log in from a device.ha.sh, 63 checks with four containers: two PostgreSQL servers (primary and streaming standby, over TLS) and two Radiator servers, through a dead primary, a hung primary, failovers, an old primary coming back, a planned switchover (a login held until the standby is promoted, and one held for 5 s, then rejected, when none is), the replay window (see Two database servers), and the same OTP sent to both Radiator servers at the same moment.geo.sh, 214 checks with three containers, one per site, each running systemd with etcd, PostgreSQL under Patroni, Radiator, FRR andyubikey-anycast.shfrom the package, set up as in Three sites and Anycast, with traffic control adding the WAN delays (55 ms between West and East, 4 ms between the East sites). A fourth container stands in for West's router (FRR), and a fifth, behind it, is a RADIUS client that logs in at the anycast addresses, over IPv4 and IPv6. It measures login times, switches the primary over twice while four users at each site log in at the same time, then stops West's Radiator and West's check, reboots each server in turn, cuts West's and then the primary's power, kills Patroni on the primary, cuts West off, cuts the primary's site off, breaks the link between the East sites, copies a standby again, and takes two servers down at once. Then it sets up partition mode, has West's check renew its key under systemd (and West's copy get it), and cuts West off, cuts the primary's site off, takes two servers down at once, and breaks the link between the East sites again. Each time it checks which logins are accepted, which OTPs are replays, that the cluster recovers, which servers the router has anycast routes from, and which server answers the client. Last, it sets up the automatic backups with the guide's steps (the key pair made on the client, as the admins' machine), checks and decrypts one there, and restores it on the primary, at its prompt, after a login: the OTP stays used, and the sites go on logging users in and advertising. The containers have theSYS_ADMINcapability (for systemd) andNET_ADMIN(for traffic control and addresses in their own network), but aren't privileged. Docker sets the two kernel settings of Set up anycast on each server, step 2, in the containers (--sysctl); the step's commands themselves were run in a container whose kernel settings it could change for its own network only.package.sh, 49 checks of the package andinstall.sh: the package builds the same twice, lintian finds nothing in it, and it puts nothing outside/opt/radiator-yubikeybut its units. Then, on fresh Ubuntu 26.04 containers (apt-getin them needs network access): without Radiator, whereinstall.shstops, or goes on with--yes; with Radiator Software's packages; with Radiator's source unpacked in/opt/radiator/radiator; and with it installed withmake install(taken from theradiatorpackage, which holds Radiator's source distribution). Each Radiator then accepts a login and rejects the same OTP again. Last, under systemd: units installed by hand as this guide said before the package, replaced withinstall.sh's commands; the package's units (and the backups' timer) neither enabled nor started by the install, and found sound bysystemd-analyze verify, restarted by an upgrade only while they run, stopped by a removal, and gone after a purge.turnkey.sh, 334 checks of the turn-key setup:yubikey-turnkeymakes the folders from asite.tomlwith two routers at West's site, andsetup.shruns on three fresh Ubuntu 26.04 containers (systemd andiproute2only) at the same time, installing everything withapt-get(it needs network access). Two more containers are the routers (FRR), and one behind them is a device, and the admins' machine. It checks the tool's output,--checkon a fresh server, the cluster, the anycast checks, both routers' routes and BFD sessions; logins at the anycast addresses over RADIUS and TACACS+, IPv4 and IPv6, by YubiKey and by service account, refused for a group without access to the device's group and from an unlisted device (the catch-all) until its group has access; West withdrawn from both of its routers when only its TACACS+ login fails, and when its check stops;setup.sh --status(OK on all three, also without the folder; and what it says with etcd stopped on one server, a router down, the primary moved, West withdrawn, and a folder copied but not set up); the backups (the first, made bysetup.sh, more on a standby, how many it keeps,--statuswith one 30 hours old and with the timer stopped; one checked and decrypted on the admins' machine, with a key pair made as this guide says, and restored on the primary after a login, a deleted YubiKey, a group change and a renewed anycast key: what it undoes, the counters and keys it keeps, logins right after, and no restore on a standby); the firewall (PostgreSQL closed to a machine outside the admin subnets, Patroni's REST API open to the admins);setup.shrun again (nothing installed or restarted); a reboot; a new device added insite.toml; new certificates, one server at a time (etcd, Patroni and PostgreSQL present them); servers rebuilt with the same names and addresses (West, whose check renewed its key, and East1, lost while it was the primary, on new containers, with logins going on meanwhile, the database's key and counter for the check, and the primary moved back; East2 started over in place with--reset),setup.shstopping at etcd while East2's is stopped, and joining when all three's was; two servers lost (East1 and East2: logins through West from its copy,setup.sh --aloneon West, the two set up again at the same time, the OTP used on West alone refused after);--reseton all three, thensetup.shagain, and the last backup restored into the new cluster; and servers replaced (West moved to new addresses, its--checkwarning before the other two knew it, and East1, lost while it was the primary, replaced by a server of a new name and addresses, which then becomes the primary); and new passwords and keys (--rotate: a standby run first waits for the primary, logins every second all along, replication with the new password, a switchover, the admin's old and new passwords and their own kept, a device's new TACACS+ key).upgrade.sh, 59 checks of upgrades, inturnkey.sh's lab: three servers set up with radiator-yubikey 1.12.0 (itsyubikey-turnkeyand package, from the git tagv1.12.0), then upgraded to this version (built from the working tree, as1.12.0.1ifVERSIONis still1.12.0), the primary first, with--statusOK after; a newer Radiator (4.30-1 again, as 4.30-1+lab1, with a changed sampleradiator.conf: the server's stays) on each; and PostgreSQL updated on each withneedrestartinstalled (the standbys, a switchover, the old primary), and on the primary without the switchover, which fails over. A TACACS+ login every second all along.
tests/radiator/run.sh ~/radiator-packages # all seven, about 65 minutes
tests/radiator/run.sh ~/radiator-packages ha # only ha.sh (or e2e, groups, geo, package, turnkey, or upgrade)
tests/radiator/run.sh ~/radiator-packages turnkey upgrade # these two
Each prints one PASS or FAIL line per check
and ends with a count, such as
Radiator 4.30-1: 145 passed, 0 failed (and
133 passed for groups.sh),
Two database servers: 63 passed, 0 failed,
Three sites: 214 passed, 0 failed,
Package: 49 passed, 0 failed,
Turnkey: 334 passed, 0 failed and
Upgrade: 59 passed, 0 failed. Run them again with each new
Radiator version.
KEEP=1 tests/radiator/ha.sh yk-radiator-test:4.30-1 (or
geo.sh) leaves the containers running afterwards, to look
around. run.sh stops a lab after about twice its usual time
(60 minutes for turnkey.sh, 30 for geo.sh) and
says so, and goes on with the next; the lab removes its containers as it
stops. In turnkey.sh and upgrade.sh,
setup.sh waits at most 5 minutes for the other servers
(TURNKEY_WAIT=300), not 30, so one that hangs fails within
minutes.
Install on the servers
Radiator server
Install Radiator first. It comes from Radiator Software, as packages or as source, and the scripts work with either.
Radiator Software's packages, as this guide uses:
sudo apt install ./radiator_4.30-1_all.deb ./radiator-radius-utilxs_2.4-4.resolute_amd64.deb
The package creates the radiator user, installs Radiator
in /opt/radiator/radiator (its tools, such as
radpwtst, aren't on the PATH), and adds the
radiator.service systemd unit, which runs Radiator as
radiator with /etc/radiator/radiator.conf.
Radiator's source: unpacked in
/opt/radiator/radiator, where the systemd unit in its
goodies directory runs it, or installed with
perl Makefile.PL && make && sudo make install
(as its Makefile.PL says), which puts radiusd
and radpwtst in /usr/local/bin. Then make what
the package would have made: the radiator user, Radiator's
directories, and the systemd unit (for a make install, with
its /opt/radiator/radiator paths changed to where
radiusd is):
sudo groupadd -r radiator
sudo useradd -r -g radiator -d /var/lib/radiator -s /usr/sbin/nologin radiator
sudo install -d /etc/radiator/conf.d
sudo install -d -o radiator -g radiator /var/log/radiator /var/lib/radiator
sudo install -m 0644 goodies/radiator.service /etc/systemd/system/ # in the source directory
Where this guide runs /opt/radiator/radiator/radiusd or
radpwtst, run yours. After a make install,
give radpwtst the source's dictionary
(-dictionary /usr/src/radiator/dictionary, for example),
and in Install the
configuration, set DictionaryFile to it too.
The radiator-yubikey package
The scripts come as a package, radiator-yubikey, which
puts them in /opt/radiator-yubikey, and
install.sh, which installs the package with the packages
that it and Radiator's YubiKey support need. Download both from a release
into one directory, and run install.sh there:
rel=https://github.com/robert-patrick-texas/radiator-yubikey/releases
v=$(curl -fsSLI -o /dev/null -w '%{url_effective}' $rel/latest); v=${v##*/v} # the latest version
curl -fLO $rel/download/v$v/radiator-yubikey_${v}_all.deb
curl -fLO $rel/download/v$v/install.sh
sudo bash install.sh
The second line finds the latest release's version, such as
1.8.0; for another release, set v to its
version instead.
install.sh:
- Looks for Radiator: Radiator Software's
radiator(orradiator-locked) package, or aradiusdfrom their source with Radiator's Perl modules, in/opt/radiator/radiatoror on thePATH(give another one with--radiusd FILE). Radiator isn't a dependency of the package, so that either kind works. When it finds neither, it says how to install Radiator, and asks whether to go on without it; with no terminal to ask on, it stops (exit status 3), unless you give--yes. Without Radiator, the scripts still manage a database, for example on a server that only holds the database. - Installs with
apt-get:libauth-yubikey-decrypter-perlandlibcrypt-rijndael-perl(Radiator's YubiKey support),libcache-fastmmap-perl(forDupCache sharedinradiator.example.conf),libdbd-sqlite3-perl,sqlite3,libdbd-pg-perl,postgresql-client, and the package. A package of the version that is installed already is left as it is. - Checks that Radiator's Perl loads Radiator's SQLYUBIKEY module and
the modules it needs, and lists what is left to do: for Radiator from
source, a missing
radiatoruser orradiator.service; for units installed by an earlier version of this guide, how to use the package's instead (see After upgrading the scripts).
sudo /opt/radiator-yubikey/install.sh --check changes
nothing, shows what is installed, and exits 0 only when nothing is
missing or left to do:
install.sh 1.12.1
Radiator: 4.30-1, Radiator Software's package radiator (with radiator-radius-utilxs 2.4-4): /opt/radiator/radiator/radiusd
Package: radiator-yubikey 1.12.1, installed
Packages:
ok libauth-yubikey-decrypter-perl 0.07-2.1
ok libcrypt-rijndael-perl 1.16-1build6
ok libcache-fastmmap-perl 1.60-1
ok libdbd-sqlite3-perl 1.78-1
ok sqlite3 3.46.1-9ubuntu0.3
ok libdbd-pg-perl 3.18.0-4
ok postgresql-client 18+290ubuntu1
Radiator's Perl (/usr/bin/perl) loads:
ok Radius::AuthSQLYUBIKEY (Radiator)
ok Auth::Yubikey_Decrypter (libauth-yubikey-decrypter-perl)
ok Crypt::Rijndael (libcrypt-rijndael-perl)
ok Cache::FastMmap (libcache-fastmmap-perl)
ok DBD::SQLite (libdbd-sqlite3-perl)
ok DBD::Pg (libdbd-pg-perl)
Nothing is missing.
The package puts the scripts, their examples,
tests/make-otp.sh, this guide
(yubikey-db-guide.html), the README and the changelog in
/opt/radiator-yubikey. It also installs the systemd units
yubikey-partition.service and
yubikey-anycast.service, for Partition mode and Anycast, and yubikey-backup.service
with yubikey-backup.timer, for Automatic backups, but enables and starts
none of them.
The examples in this guide run from
/opt/radiator-yubikey
(cd /opt/radiator-yubikey). With SQLite, run the scripts
with sudo, because the database belongs to
radiator. With PostgreSQL, run them as yourself, as a
member of the admin role (set up in Create the database);
export DB_BACKEND=pg in your ~/.profile saves
typing -b pg every time.
To upgrade, run the new release's
install.sh with its package, the same way, then see After upgrading the scripts. The
services that run (yubikey-partition,
yubikey-anycast) restart with the new scripts; one that you
stopped stays stopped. While yubikey-anycast restarts, the
server's anycast addresses are off lo for 5 to 10 s, and
the routers send RADIUS to another site.
To remove the scripts,
sudo apt remove radiator-yubikey: it stops the two services
and removes /opt/radiator-yubikey. The database, and
Radiator's configuration and the settings in /etc/radiator,
stay.
Without the package, clone the repository to the
same place; its install.sh then installs the other packages
only:
sudo git clone https://github.com/robert-patrick-texas/radiator-yubikey.git /opt/radiator-yubikey
cd /opt/radiator-yubikey
sudo ./install.sh
For partition mode, anycast and the backups, install their units
yourself:
sudo install -m 0644 yubikey-partition.service yubikey-anycast.service yubikey-backup.service yubikey-backup.timer /etc/systemd/system/,
then sudo systemctl daemon-reload. To build the package
from a clone, see packaging/build-deb.sh.
PostgreSQL server
On the server that will hold the database (the Radiator server itself, or a separate one):
sudo apt install postgresql
Program the YubiKeys
Do this once for each YubiKey, on a trusted workstation. The command overwrites OTP slot 2, which types an OTP on a long touch (slot 1 is the short touch).
Warning
Slot 1's factory configuration can't be restored. After you reprogram it, the key no longer works with services that check OTPs through YubiCloud. Use slot 2 unless you're sure.
ykman otp yubico 2 --serial-public-id --generate-private-id --generate-key
ykman shows three values. Record them for this user:
| ykman output | yubikey-db.sh option |
CSV column | Format |
|---|---|---|---|
| Public ID | -P |
public_id |
12 modhex characters (only cbdefghijklnrtuv) |
| Private ID | -p |
private_id |
12 hex characters |
| Secret key | -k |
aes_key |
32 hex characters |
If a user gets more than one YubiKey, also note which is which, and
the slot, for example primary, slot 2 and
spare, slot 2: store that as the YubiKey's label
(-l, or the CSV column label), which
list shows.
Caution
Do not add --upload: it sends the key
to Yubico.
To choose the public ID yourself, replace
--serial-public-id with
--public-id <12 modhex characters>. Radiator accepts
only 12-character public IDs.
Create the database
SQLite
sudo ./yubikey-db.sh init --owner radiator:radiator
This creates /etc/radiator/db/yubicred.db (use
-d FILE for another path). Radiator writes to the database
after every login, and SQLite writes a journal file next to it, so
--owner gives the radiator user the file
and its folder. init is safe to run
again.
PostgreSQL on the Radiator server
Create the roles and the database as the postgres
superuser, and make yourself an admin:
sudo -u postgres ./yubikey-db.sh -b pg init-pg --admin "$USER"
This creates:
- the database
yubikey(-d NAMEfor another name), with theyubikeystable; - the role
yubikey_admin, which owns them;--admin LOGINmakes a login a member, and you can repeat it; - the role
radiator, Radiator's login. It can read the lookup columns and update only the counters and the access time.
Radiator connects over the local socket, where Ubuntu's PostgreSQL
uses peer authentication: the radiator OS user logs in as
the radiator role with no password. Admins log in the same
way as themselves.
PostgreSQL on a separate server
Radiator then connects over the network, with TLS and a password.
Below, the database server is db.example.com, the Radiator
server 192.0.2.21, and the admin opsadmin
works on the Radiator server.
On the database server:
Install a TLS certificate for the name Radiator connects to. Use your own CA if you have one. For a lab, make a CA and a certificate:
openssl req -x509 -newkey rsa:3072 -nodes -days 3650 -subj "/CN=YubiKey DB CA" -keyout ca.key -out ca.crt openssl req -newkey rsa:3072 -nodes -subj "/CN=db.example.com" -addext "subjectAltName=DNS:db.example.com" \ -keyout server.key -out server.csr openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -days 825 -copy_extensions copy -out server.crt sudo install -m 0644 -o postgres -g postgres server.crt /etc/postgresql/18/main/server.crt sudo install -m 0600 -o postgres -g postgres server.key /etc/postgresql/18/main/server.keyKeep
ca.keyoffline. Radiator's server needs onlyca.crt.Make PostgreSQL listen on the network with that certificate:
sudo tee /etc/postgresql/18/main/conf.d/yubikey.conf >/dev/null <<'EOF' listen_addresses = '*' ssl = on ssl_cert_file = '/etc/postgresql/18/main/server.crt' ssl_key_file = '/etc/postgresql/18/main/server.key' EOFAllow
radiatorandopsadminfrom the Radiator server, over TLS only. Add these lines to/etc/postgresql/18/main/pg_hba.confabove the first line that starts withhost:hostssl yubikey radiator 192.0.2.21/32 scram-sha-256 hostssl yubikey opsadmin 192.0.2.21/32 scram-sha-256Then
sudo systemctl restart postgresql, and open TCP port 5432 to192.0.2.21in any firewall.Create the roles and the database, set Radiator's password (it asks for it), and give the admin a password:
sudo -u postgres ./yubikey-db.sh -b pg init-pg --admin opsadmin --set-password sudo -u postgres psql -c '\password opsadmin'This needs the scripts on the database server too, only for this step.
On the Radiator server, as
opsadmin:
Install the CA certificate where Radiator and the admins can read it:
sudo install -m 0644 ca.crt /etc/radiator/pg-ca.crtTell
psqland the scripts where the database is, and store the password (~/.pgpassmust be mode0600):cat >> ~/.profile <<'EOF' export PGHOST=db.example.com PGUSER=opsadmin PGSSLMODE=verify-full PGSSLROOTCERT=/etc/radiator/pg-ca.crt export DB_BACKEND=pg EOF (umask 077; echo 'db.example.com:5432:yubikey:opsadmin:YOUR-PASSWORD' >> ~/.pgpass) . ~/.profile ./yubikey-db.sh listlistshows an empty table when everything works.verify-fullchecks that the server's certificate is fordb.example.comand signed by your CA.
With these settings, ./yubikey-db.sh config writes the
same connection into Radiator's configuration: host,
port, sslmode=verify-full and
sslrootcert, and connect_timeout=2. You add
Radiator's password in the next section. For a second database server
that takes over when this one fails, see Two database servers.
Configure and start Radiator
Install the configuration
The Radiator package's sample
/etc/radiator/radiator.conf is for a first test only.
Caution
The sample radiator.conf accepts every login
from 127.0.0.1 without a password check, and accepts requests
from any client that uses the secret
mysecret. If you add the YubiKey clause to it, a test with
radpwtst on the server succeeds without ever reaching the
database. Replace it.
Install radiator.example.conf in its place, then set
your RADIUS clients:
sudo cp -p /etc/radiator/radiator.conf /etc/radiator/radiator.conf.orig
sudo install -m 0640 -o root -g radiator radiator.example.conf /etc/radiator/radiator.conf
sudoedit /etc/radiator/radiator.conf
In the editor, change the Secret of
<Client 127.0.0.1> (used for tests on the server),
and replace the <Client 192.0.2.10> example with one
block per RADIUS client (VPN gateway, switch, wireless controller), each
with its own long random secret.
radiator.example.conf runs four Radiator processes
(FarmSize 4). Each answers one request at a time, so one
slow database query, for example while a database server fails over, no
longer holds up every other login. DupCache shared lets the
processes recognize a request that a RADIUS client sent again to another
process; it needs libcache-fastmmap-perl (installed above)
and keeps a file of about 290 MB at
/var/lib/radiator/dupcache while Radiator runs.
Write the YubiKey clause from yubikey-db.sh config to
/etc/radiator/conf.d/yubikey.conf, which
radiator.conf includes. Create the file first, so it is
readable only by root and radiator; tee keeps
that owner and mode:
sudo touch /etc/radiator/conf.d/yubikey.conf
sudo chown root:radiator /etc/radiator/conf.d/yubikey.conf
sudo chmod 0640 /etc/radiator/conf.d/yubikey.conf
./yubikey-db.sh config | sudo tee /etc/radiator/conf.d/yubikey.conf >/dev/null # SQLite
./yubikey-db.sh -b pg config | sudo tee /etc/radiator/conf.d/yubikey.conf >/dev/null # PostgreSQL
For PostgreSQL on a separate server, also put Radiator's password on
the DBAuth lines
(sudoedit /etc/radiator/conf.d/yubikey.conf): one in each
clause, YubiKeySQL, ServiceAccounts and
GroupAccess. This is why the file must not be readable by
other users.
The clause it writes, for SQLite:
<AuthBy SQLYUBIKEY>
Identifier YubiKeyLocal
DBSource dbi:SQLite:dbname=/etc/radiator/db/yubicred.db
DBUsername
DBAuth
AuthSelect select secret, active, userId, counter, low, high, password from yubikeys where username=%0 and tokenId=%3
UpdateQuery update yubikeys set accessed=datetime(%5,'unixepoch'), counter=%0, low=%1, high=%2 where username=%3 and tokenId=%7
Require2Factor
CheckSecretId
NoDefault
</AuthBy>
AuthSelectfinds the row by both the username and the public ID, so a user can have several YubiKeys and one user's YubiKey can't log in as another user.UpdateQuerysaves the counters after each login. For PostgreSQL it usesto_timestamp(%5), because Radiator's defaultcurrent_timestamp()isn't valid there, and it names the login withset_config, so that the same login may save the same counters again (see Database layout).CheckSecretIdchecks the private ID inside the OTP against the database.NoDefault: never fall back to a user calledDEFAULT, as some Radiator clauses do for a user they don't find. Radiator's YubiKey clauses already don't, by default; the line keeps it so.Require2Factor: see below.- For PostgreSQL,
configalso writesTimeout 2,ConnectTimeoutandFailureBackoffTime 2, so Radiator gives up quickly on a database server that doesn't answer and tries again after 2 s, not 600 s, and hooks that hold a login for a few seconds while the primary changes (see Configure Radiator for two servers). The YubiKey clause is thenYubiKeySQL, andYubiKeyLocal, the name the Handlers use, is a group of two clauses:YubiKeyConnect, which connects to the database first, thenYubiKeySQL.
Choose: PIN and OTP, or OTP only
Require2Factor |
Users with a PIN | Users without a PIN |
|---|---|---|
Set (as config writes it) |
Must type PIN:OTP or PINOTP |
Always rejected |
| Removed | May type just the OTP: the PIN is checked only if they type one | Type just the OTP |
Keep Require2Factor and give every user a PIN, unless
you've decided on OTP-only logins for everyone. Without it, the PIN is
optional even for users who have one. If you remove it,
also use verify --no-require2factor (and
require2factor=no in the example wrapper), so their checks
match Radiator.
Check and start
Check the configuration as the radiator user, then look
at the end of the log:
sudo -u radiator /opt/radiator/radiator/radiusd -c -foreground -no_pid_file -config_file /etc/radiator/radiator.conf
sudo tail -n 5 /var/log/radiator/radiator.log
Fix anything reported as ERR or WARNING;
radiusd -c exits with 0 even then.
Important
Run radiusd checks as radiator, never as
root. Run as root, it creates
/var/log/radiator/radiator.log owned by root, and the
service can then no longer write its log
(Log could not append ... Permission denied). If that
happened,
sudo chown radiator:radiator /var/log/radiator/*.
Start Radiator now and at every boot, and check it's running:
sudo systemctl enable --now radiator
systemctl status radiator
After each change to the configuration,
sudo systemctl restart radiator. If a firewall runs on the
server, allow RADIUS from each client, for example
sudo ufw allow from 192.0.2.10 to any port 1812 proto udp.
Radiator logs to /var/log/radiator/radiator.log, and
each login, with the reason for a reject, to
/var/log/radiator/authentication.log. Only root and
radiator can read them, so use sudo to read
them.
Add accounts
A row in the database is one YubiKey of one user. A user can have
several YubiKeys: Radiator finds the right one by the public ID at the
start of the OTP, and a label such as
primary, slot 1 or spare, slot 2 tells them
apart for you (Radiator never reads it). Each command below is for
SQLite; add -b pg (or set DB_BACKEND=pg) for
PostgreSQL, and leave out sudo there.
One account
add asks for each value you leave out; the private ID,
AES key and PIN aren't shown as you type. Press Enter at the label
prompt for none:
sudo ./yubikey-db.sh add # asks for everything
sudo ./yubikey-db.sh add -u alice -P vvccccbbcdef # asks for the private ID, key, PIN and label
sudo ./yubikey-db.sh add -u alice -P vvccccbbcdeg -l 'spare, slot 2'
sudo ./yubikey-db.sh add -u kiosk -P vvccccbbcdeh --no-password -l ''
PINs are stored as salted SHA-512 {crypt} hashes.
Options -k (AES key) and -w (PIN) also exist,
but other users can see them in ps and they stay in your
shell history, so let the script ask instead.
If the YubiKey may already be in the database, add
--update (see Update and
replace).
Many accounts from a CSV file
username,public_id,private_id,aes_key,password,active,label
alice,vvccccbbcdef,4a6f8e2d1c3b,6b1f0a9d2c7e4f3a8b5d0c1e9f2a7b6d,1234,1,primary slot 1
alice,vvccccbbcdeg,1b2c3d4e5f60,8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d,1234,1,spare slot 2
bob,vvccccbbcdeh,9e8d7c6b5a41,0f1e2d3c4b5a69788796a5b4c3d2e1f0,5678,yes
| Column | Required | Format |
|---|---|---|
username |
Yes | The RADIUS login name: letters, digits and
. _ @ + - |
public_id |
Yes | 12 modhex characters |
private_id |
Yes | 12 hex characters (12 modhex characters are converted to hex) |
aes_key |
Yes | 32 hex characters; spaces and colons are removed |
password |
No | The PIN; empty for none |
active |
No | 1/0, true/false,
yes/no; default 1 |
label |
No | A description of the YubiKey, up to 255 characters; empty for none |
The header row is optional; blank lines and lines starting with
# are skipped; Windows line endings are accepted; spaces
and double quotes around a field are removed; fields can't
contain commas (use the label command for a label
with a comma). Files without the label column, as earlier
versions wrote them, still load.
chmod 600 users.csv # it holds AES keys
sudo ./yubikey-db.sh import --dry-run users.csv # check every row; changes nothing
sudo ./yubikey-db.sh import users.csv
shred -u users.csv
Every row is checked first, and if any is invalid, nothing is
imported (--skip-invalid loads the valid rows). Valid rows
are loaded in one transaction: all of them or none. Use -
to read the CSV from standard input, for example from an encrypted file:
gpg -d users.csv.gpg | sudo ./yubikey-db.sh import -.
A test account from a script
yubikey-db-user.example.sh adds or updates one account
from variables at the top of a copy of it, which suits test accounts you
set up again and again. It sends the values to
import --update - through a pipe, so they never appear in
ps or a temporary file, shows the account, and then asks
you to touch the YubiKey to run verify.
install -m 0700 yubikey-db-user.example.sh ~/yk-user.sh # your copy will hold an AES key
nano ~/yk-user.sh # set cred_username, cred_password, cred_publicid, cred_privateid, cred_secret,
# cred_label if you want one, and db_backend, db_name and require2factor
# if not the defaults
sudo ~/yk-user.sh # SQLite; for PostgreSQL run it as yourself
shred -u ~/yk-user.sh # when done
The YubiKey's public ID decides which record is meant; other YubiKeys
of the user are left alone. The PIN can't contain a comma, start or end
with a space, or be in double quotes, and the label can't contain a
comma. An empty cred_label keeps the label the YubiKey
already has.
Verify credentials
Check the database: verify
verify asks for a username, PIN and OTP and checks them
the way Radiator 4.30 does, without Radiator, so it separates "the
database is wrong" from "Radiator is misconfigured":
sudo ./yubikey-db.sh verify
Username: alice
Static password/PIN (Enter for none):
YubiKey OTP (touch the YubiKey): vvccccbbcdefvbvkiggllhvureilrdbnvklrcfivibet
Checking alice with YubiKey vvccccbbcdef in /etc/radiator/db/yubicred.db (Require2Factor on):
active: yes
static password: matches
OTP: decrypts with the stored AES key
private ID: matches
replay check: passed (OTP counter 3, session 1; stored counter 0, session 0)
Accepted. The OTP's counters were saved, so it cannot be used again.
Each line is one check, so a failure says what to fix: a
WRONG PIN, an OTP that doesn't decrypt (the database has a
different AES key from the YubiKey), a private ID that doesn't match, or
a replayed OTP. It never shows the AES key or private ID. When the
YubiKey has a label, a first line label: shows it, so you
can see which of the user's YubiKeys was used. An accepted OTP's
counters are saved, as Radiator saves them.
-u USERgives the username.--no-require2factormatches a Radiator withoutRequire2Factor.- Without a terminal, it reads the username (unless
-uis given), the PIN and the OTP from standard input, one per line:printf '%s\n' "$pin" "$otp" | ./yubikey-db.sh verify -u alice. - It exits with 0 if Radiator would accept the login, 1 if not.
Check the whole path: radpwtst
radpwtst sends a real RADIUS request, so it also tests
Radiator's configuration. On the Radiator server, with the secret of
<Client 127.0.0.1>:
read -rsp 'PIN: ' PIN; echo; read -rp 'Touch the YubiKey: ' OTP
/opt/radiator/radiator/radpwtst -s 127.0.0.1 -secret local-test-secret -auth_port 1812 -noacct -user alice -password "$PIN:$OTP"
sudo tail -n 1 /var/log/radiator/authentication.log
It prints OK, or Rejected: Request Denied
with the reason in the log. Send the same OTP again: it must be rejected
with Replay attack detected. Without a YubiKey, make the
OTP of a test key with tests/make-otp.sh (see the lab).
-auth_port 1812 matters: radpwtst uses port
1645 by default.
Finally, log in through a real RADIUS client: users type their PIN and then touch the YubiKey, in the password field.
Maintain accounts
Add -b pg for PostgreSQL. list never shows
AES keys or PINs; it shows each YubiKey's public ID,
active, times, counter and label.
| Task | Command |
|---|---|
| List all users and YubiKeys | sudo ./yubikey-db.sh list |
| Show one user | sudo ./yubikey-db.sh list alice |
| Check a user's PIN and OTP | sudo ./yubikey-db.sh verify -u alice |
| Change a PIN (counters kept) | sudo ./yubikey-db.sh add --update -u alice -P vvccccbbcdef |
| Give a user another YubiKey | sudo ./yubikey-db.sh add -u alice -P vvccccbbcdeg -l 'spare, slot 2' |
| Label a YubiKey, or change its label | sudo ./yubikey-db.sh label alice vvccccbbcdeg 'spare, slot 2' |
| Remove a YubiKey's label | sudo ./yubikey-db.sh label alice vvccccbbcdeg '' |
| Suspend a user (all their YubiKeys) | sudo ./yubikey-db.sh disable alice |
| Suspend one lost YubiKey | sudo ./yubikey-db.sh disable alice vvccccbbcdef |
| Turn a user back on | sudo ./yubikey-db.sh enable alice |
| Remove one YubiKey | sudo ./yubikey-db.sh delete alice vvccccbbcdef |
| Remove a user | sudo ./yubikey-db.sh delete alice |
| Back up every YubiKey, encrypted | sudo ./yubikey-db.sh export - | gpg -e -r backup@example.com > yubikeys.csv.gpg |
| Restore a backup | gpg -d yubikeys.csv.gpg | sudo ./yubikey-db.sh import --from-export - |
Update and replace
add and import refuse a YubiKey that is
already in the database. Two options change that:
--update |
--replace |
|
|---|---|---|
| Same YubiKey (same public ID, private ID and AES key) | Username, PIN and active updated; counters and
times kept |
Replaced; counters reset to 0 |
| Reprogrammed YubiKey (same public ID, new private ID or AES key) | Replaced; counters reset to 0 | Replaced; counters reset to 0 |
| New YubiKey | Added | Added |
| Label | The one given; without one, the YubiKey's label is kept | The one given; without one, the YubiKey's label is kept |
Use --update to change a PIN or move a YubiKey to
another user: resetting the counters would let OTPs that were already
used be accepted again. Use --replace only after
programming the same values into a YubiKey again (for
example into a replacement YubiKey), because the YubiKey's own counters
then start again from 0. Each updated or replaced row is reported.
A lost or stolen YubiKey
- Disable it at once:
sudo ./yubikey-db.sh disable alice vvccccbbcdef. Logins are checked only against this database, so nothing needs revoking at Yubico. - When it won't come back, delete it:
sudo ./yubikey-db.sh delete alice vvccccbbcdef. - Program a new YubiKey and add it.
Backups
export writes every row and field to CSV, with AES keys
as 32 hex characters, and import --from-export loads it
into either database.
sudo ./yubikey-db.sh export - | gpg -e -r backup@example.com > yubikeys-$(date +%F).csv.gpg
gpg -d yubikeys-2026-10-12.csv.gpg | sudo ./yubikey-db.sh import --from-export --dry-run -
gpg -d yubikeys-2026-10-12.csv.gpg | sudo ./yubikey-db.sh import --from-export --replace -
A restore brings back PINs (as stored), times and counters. With
--replace it overwrites existing rows but never
moves counters backward, so an old backup can't make used OTPs
valid again. export FILE writes a file with mode
0600 instead, and refuses to overwrite one unless you add
--force.
You can also back up the database itself: for SQLite,
sudo sqlite3 /etc/radiator/db/yubicred.db ".backup '/root/yubicred-backup.db'"
takes a consistent copy while Radiator runs; for PostgreSQL,
yubikey-backup.sh makes one every day (see Automatic backups). Only such a backup
holds the service accounts and groups
too. Every backup holds every AES key: keep it encrypted.
Automatic backups
For PostgreSQL, yubikey-backup.sh backs up the whole
database every day on each database server: the YubiKeys, the service
accounts and the groups.
- From the server's own PostgreSQL.
pg_dumpreads the local database, a standby's too, so each server of two or three keeps its own backups. - Encrypted for a key that isn't on the servers. The
dump goes straight into
openssl cms: AES-256-GCM, with a new key for each file, wrapped with RSA-OAEP for a certificate whose private key you keep elsewhere. It is never on disk in clear, and a copy of a backup is of no use without the key. - Kept on the server.
/var/backups/yubikey-db.cms.0is the newest: each run moves the others up one (.0to.1,.1to.2, ...), and keeps 7 (.0to.6;KEEPsets how many). They are mode0640, groupadm, so an admin in that group can fetch them. - Checked and restored with the same script.
checkdecrypts a backup and reads it back whole, anddecryptwrites the dump, on any machine withopensslandpg_restore, and only when the file is whole and unchanged (opensslalone writes out what it decrypted even then).restore, on the primary, says what it would undo and asks first.
1. Make the key pair on an admin's machine, not on a server:
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -aes-256-cbc -out backup.key
openssl req -new -x509 -key backup.key -days 7300 -subj "/CN=yubikey backups" -out backup.crt
openssl asks for a passphrase for the key (twice), and
for it again to sign the certificate. Keep backup.key in
two places, apart from each other and from the servers (two USB drives
in two safes, say), and the passphrase on its own (a password manager):
whoever has both can read every AES key in every backup. Test that you
can decrypt (step 4) before you rely on it. backup.crt
isn't secret. It lasts 20 years, but the backups decrypt after it
expires too. When you make a new pair, keep the old key as long as you
keep backups made for it.
2. On each database server, the certificate, the settings and the timer, and a first backup now:
sudo install -D -m 0644 backup.crt /etc/radiator/backup.crt
sudo install -D -m 0644 /opt/radiator-yubikey/yubikey-backup.example.conf /etc/radiator/backup.conf
sudo systemctl enable --now yubikey-backup.timer
sudo systemctl start yubikey-backup.service
sudo /opt/radiator-yubikey/yubikey-backup.sh status
the newest backup is 0 min old
newest: /var/backups/yubikey-db.cms.0, 2026-10-16 22:53:42 UTC, 20 KB
kept: 1 of 7, the oldest yubikey-db.cms.0 of 2026-10-16 22:53:42 UTC
last run: 2026-10-16 22:53:42 UTC, ok
encrypted to: CN = yubikey backups (SHA-256 06:9E:AE:5E:EA:1C:1E:C0:E8:55:CC:E1:85:A9:7A:14:CE:F8:99:19:DC:B3:A2:4C:87:8D:B9:02:2B:CF:6B:F5), until 2046-10-11
The timer starts it every day at about 03:00 (the server's time), or
at the next boot when the server was off then. status exits
with 0 when the newest backup is less than 26 hours old and the last run
worked, and 1 otherwise, so monitoring can run it;
journalctl -u yubikey-backup has each run.
3. Fetch them to where you keep backups, as a member
of the group adm on the server
(sudo usermod -aG adm alice):
scp db1.example.com:/var/backups/yubikey-db.cms.0 db1-$(date +%F).cms
4. Check one now and then, on your machine (with
postgresql-client and openssl). It changes
nothing:
./yubikey-backup.sh check --key backup.key db1-2026-10-16.cms
Enter pass phrase for backup.key:
db1-2026-10-16.cms: a whole backup of the YubiKey database, made 2026-10-16 22:53:41 UTC (PostgreSQL 18.6)
it holds 5 YubiKeys (of 5 users), 4 service accounts, 4 groups
5. To restore one, decrypt it on your machine (the key stays there), copy the dump to the primary, restore it there, and remove the dump from both, since it holds every AES key in clear:
./yubikey-backup.sh decrypt --key backup.key db1-2026-10-16.cms yubikey-db.dump
scp yubikey-db.dump db1.example.com:
ssh -t db1.example.com sudo /opt/radiator-yubikey/yubikey-backup.sh restore yubikey-db.dump
ssh db1.example.com shred -u yubikey-db.dump && shred -u yubikey-db.dump
Enter pass phrase for backup.key:
decrypted db1-2026-10-16.cms to yubikey-db.dump (20 KB, mode 0600)
The backup, made 2026-10-16 22:53:41 UTC: 5 YubiKeys (of 5 users), 4 service accounts, 4 groups.
The database yubikey now: 6 YubiKeys (of 6 users), 4 service accounts, 4 groups.
The restore puts the backup's tables back, and so undoes what changed since:
YubiKeys added since the backup, which go: carol vvccccbbcdeh
YubiKeys changed since the backup, which are put back as they were: bob vvccccbbcdeg (enabled again)
Counters that are ahead now stay, and so do the anycast checks' keys.
Restore it into yubikey? Type yes: yes
Restored the backup of 2026-10-16 22:53:41 UTC into yubikey: 5 YubiKeys, 4 service accounts, 4 groups.
Kept the counters of 1 YubiKey(s) that were ahead, and the keys of 3 anycast check(s).
In this example, alice logged in after the backup, bob's YubiKey was disabled and carol's added. The restore replaces every table of the database with the backup's, in one transaction (logins wait for it: a tenth of a second in the test), and so undoes what changed since the backup, which it lists first. Two things stay as they are:
- Counters that are ahead now, so an OTP used since the backup stays used. A YubiKey deleted since the backup comes back with the backup's counters, and when the database is gone (a new one), every YubiKey has them: an OTP used after the backup could then be used once more, until the YubiKey's next login.
- The anycast checks' keys (
anycast-*), which renew themselves: the checks go on logging in.
A YubiKey or account the list says is enabled again was
disabled after the backup: disable it again. It runs only on the primary
(on a standby it says so), and takes the backup decrypted already, or
encrypted with --key.
After upgrading the scripts
From scripts installed by hand, as this guide said before version
1.6.0 (a clone in /opt/yubikey-db, and for three sites,
copies in /usr/local/sbin and units in
/etc/systemd/system), install the package as in The radiator-yubikey package. A
unit in /etc/systemd/system hides the package's, which runs
the script in /opt/radiator-yubikey;
sudo /opt/radiator-yubikey/install.sh --check lists each
one with the commands that replace it, for example:
To do:
- /etc/systemd/system/yubikey-anycast.service, installed by hand, hides the
package's unit (which runs /opt/radiator-yubikey/yubikey-anycast.sh). To
use the package's:
sudo systemctl disable yubikey-anycast.service
sudo rm /etc/systemd/system/yubikey-anycast.service
sudo systemctl daemon-reload
sudo systemctl enable yubikey-anycast.service
sudo systemctl try-restart yubikey-anycast.service
Then remove the copies in /usr/local/sbin and the old
clone.
After every upgrade, run init again
(sudo ./yubikey-db.sh init --owner radiator:radiator, or
./yubikey-db.sh -b pg init as an admin). It adds what a
newer version needs to an existing database and changes nothing else.
Version 1.2.1 adds the trigger that stops the same OTP being accepted
twice at the same moment (see Database
layout). Version 1.8.0 adds the label column:
init prints
Added the label column to the yubikeys table, and until you
run it, the other commands stop with
... was made by an older version of yubikey-db.sh; run 'yubikey-db.sh init' to bring it up to date.
Radiator doesn't need the column, so it keeps working before and after.
Version 1.9.0 adds the login_id column to PostgreSQL's
table, and changes the trigger to use it: init prints
Added the login_id column to the yubikeys table. Then write
Radiator's clause again with config on each Radiator server
(as in Install the
configuration; with partition mode, as
yubikey-cluster.conf), put Radiator's password back on the
DBAuth lines, and restart Radiator. Either can come first:
until both are done, Radiator works as before, and once both are, a
login whose counters were saved just as the primary stopped is answered
(see Database layout). Version 1.10.0
adds the service-account and group tables (init prints
Added the service-account and group tables), and in
PostgreSQL the function with which the anycast check renews its key
(init prints
Added the function anycast_renew, with which the anycast check renews its key);
until then, the check logs that it can't renew its key, and works as
before. With two database servers or three sites, run init
once, against the primary; the standbys get the change by
replication.
If your own tools add rows with
INSERT INTO yubikeys VALUES (...), without naming the
columns, as the old example commands did, they fail once the table has
the label column: name the columns, or use add
or import.
A database from the old example commands
The example statements that circulated before these scripts stored
created as the text current_timestamp, and
active as the text true or false.
Radiator treats both texts as active, so a YubiKey set
to false was never disabled. Fix those rows:
sudo sqlite3 /etc/radiator/db/yubicred.db <<'SQL'
UPDATE yubikeys SET active = 1 WHERE active = 'true';
UPDATE yubikeys SET active = 0 WHERE active = 'false';
UPDATE yubikeys SET created = CURRENT_TIMESTAMP WHERE created = 'current_timestamp';
SQL
They also stored AES keys as hex, private IDs as modhex, and PINs in
plain text. Radiator reads hex keys and plain-text PINs, but with
CheckSecretId it rejects every login of a YubiKey whose
private ID is stored as modhex. export and then
import --from-export --replace rewrite every row in the
format these scripts use (a modhex private ID becomes hex) and keep the
counters; so does a move to PostgreSQL, which also reports each
change.
Move from SQLite to PostgreSQL
yubikey-db-migrate.sh copies every user and YubiKey,
with their counters and labels, from the SQLite
database into an empty PostgreSQL database, in one transaction. It reads
a consistent snapshot, so Radiator keeps running on SQLite meanwhile,
and commits only after checking every copied row against an independent
SHA-256 fingerprint of the snapshot. Rows from the old example commands
are normalized and each change is reported (for example, an
active of false becomes 0, and the report
warns that those YubiKeys could log in before).
It copies only the YubiKeys. Service
accounts and groups aren't copied: it warns when the SQLite database
has any, and you add them again with
yubikey-db.sh -b pg.
The account that runs it must be a member of
yubikey_admin and able to read the SQLite file, for example
by joining the radiator group
(sudo usermod -aG radiator "$USER", then log in again).
- Create the PostgreSQL database (see Create the database):
sudo -u postgres ./yubikey-db.sh -b pg init-pg --admin "$USER". For a database made by an older version, run./yubikey-db.sh -b pg initinstead, which brings it up to date. - Rehearse:
./yubikey-db-migrate.sh --dry-run /etc/radiator/db/yubicred.db. It does everything, then rolls back. - Copy:
./yubikey-db-migrate.sh /etc/radiator/db/yubicred.db. - Point Radiator at PostgreSQL:
./yubikey-db.sh -b pg config | sudo tee /etc/radiator/conf.d/yubikey.conf >/dev/null, thensudo systemctl restart radiator. - Straight away, catch up with the logins made on SQLite since step 3:
./yubikey-db-migrate.sh --mode sync /etc/radiator/db/yubicred.db. It moves counters forward where SQLite is ahead and adds YubiKeys added meanwhile. It never moves a counter backward, never deletes, and lists (without changing) rows that differ in username, PIN,active, key or label.
Without step 5, an OTP used on SQLite after the copy could be used once more on PostgreSQL. Keep the SQLite file afterwards, unchanged, for a rollback.
To roll back to SQLite, first copy PostgreSQL's counters (and any users added there) into SQLite, then switch:
./yubikey-db.sh -b pg export - | sudo ./yubikey-db.sh import --from-export --replace -
./yubikey-db.sh config | sudo tee /etc/radiator/conf.d/yubikey.conf >/dev/null
sudo systemctl restart radiator
Two database servers
With one PostgreSQL server, logins stop when it does. A second
server, kept up to date by PostgreSQL's built-in streaming replication,
can take over. tests/radiator/ha.sh runs everything in this
section with two database servers and two Radiator servers (see Testing).
How it works
- One primary, one standby. The primary accepts reads and writes. The standby receives each change from the primary as it happens (in the test, about 6 ms later) and accepts only reads.
- Every Radiator server lists both servers in one
DBSource, withtarget_session_attrs=read-write. PostgreSQL's client library tries them in order and connects only to the one that accepts writes. So every Radiator server uses the primary, whichever server it lists first, and they all share one set of counters: an OTP used on one Radiator server is rejected on the others. - A failover is a promotion. When the primary fails, you promote the standby with one command. It starts accepting writes, and every Radiator server connects to it within 10 seconds, with no change to its configuration. The failed server then comes back as the new standby.
Designs that look simpler but allow replays:
- Two independent databases, one per Radiator server,
or two
DBSourcelines that point to separate databases: each keeps its own counters, so an OTP used on one is accepted on the other. - Two writable servers that replicate to each other (PostgreSQL logical replication both ways): an OTP used on one server is accepted on the other until the change arrives, during normal operation, not only during failures. And when both servers update the same row, the change that arrives last wins, which can move a counter backward.
The replay window
Replication is asynchronous: the primary saves a login's counters and answers Radiator without waiting for the standby. If the primary dies in that moment, the promoted standby doesn't have the newest counters, and those OTPs can be used once more. Only OTPs used in the last fraction of a second before the failure are affected, and only until the user's next login moves the counter past them. In normal operation there is no window, because every Radiator server writes to the same primary. The test shows the window by stopping replication before a failover.
Synchronous replication (synchronous_standby_names)
closes the window, but with only two servers every login then stops
whenever the standby is down. This guide uses asynchronous
replication.
What users see during a failover
Measured by the test, with the settings
yubikey-db.sh config writes:
| Situation | What Radiator does |
|---|---|
| The primary died or hangs, and the standby isn't promoted yet | It rejects every login with
Yubikey secret not found in database. The first login after
the failure takes 2 to 5 s (up to 2 s waiting for a query, plus up to 2
s for each server that doesn't answer), and so may the first one in each
of Radiator's four farm processes. A process that has seen the failure
rejects at once for the next 2 s (FailureBackoffTime)
without trying, then tries again. |
| The primary was stopped cleanly, or is restarting, and the standby answers but isn't promoted yet | It holds each login and tries again every 0.5 s, for up to 5 s: a login held until the standby is promoted is accepted, only later. After 5 s it rejects, as above. Each of the four farm processes holds one login at a time. |
| The standby has been promoted | Logins work again within 2 to 3 s. |
| A Radiator server's connection to the old primary ended, and a writable server exists | It reconnects and answers the same login: nothing is rejected. That includes a login whose counters the old primary saved just before it stopped: Radiator saves them again on the new one. |
| An OTP used before the failure | Still rejected as a replay, unless it was used in the replay window. |
Radiator rejects logins while no server accepts writes, after holding
them for up to 5 s if every server answers; it doesn't stay silent.
Without the hooks that config writes, its log says
Request is ignored, but the YubiKey check returns a reject.
Network devices that try their local accounts only when no RADIUS server
answers won't fall back to them, so keep your usual break-glass
procedure for a long outage.
Set up the two servers
Below, the database servers are db1.example.com
(192.0.2.11, the first primary) and
db2.example.com (192.0.2.12), and the Radiator
servers are 192.0.2.21 and 192.0.2.22. The
admin opsadmin works on 192.0.2.21.
On both database servers:
Install PostgreSQL and a certificate for that server's own name, made as in PostgreSQL on a separate server (step 1) and signed by the same CA:
CN=db1.example.comandsubjectAltName=DNS:db1.example.comon db1, and the same withdb2on db2.Make PostgreSQL listen with TLS and keep recent changes for the standby:
sudo tee /etc/postgresql/18/main/conf.d/yubikey.conf >/dev/null <<'EOF' listen_addresses = '*' ssl = on ssl_cert_file = '/etc/postgresql/18/main/server.crt' ssl_key_file = '/etc/postgresql/18/main/server.key' wal_keep_size = '1GB' EOFwal_keep_sizekeeps the last 1 GB of changes on the primary, so a standby that was offline for a while can catch up. A login adds at most about 8 KB of changes, so that covers well over 100,000 logins.Add the same lines on both servers, since either can be the primary, to
/etc/postgresql/18/main/pg_hba.conf, above the first line that starts withhost:hostssl yubikey radiator 192.0.2.21/32 scram-sha-256 hostssl yubikey radiator 192.0.2.22/32 scram-sha-256 hostssl yubikey opsadmin 192.0.2.21/32 scram-sha-256 hostssl replication replicator 192.0.2.11/32 scram-sha-256 hostssl replication replicator 192.0.2.12/32 scram-sha-256Open TCP port 5432 to these addresses in any firewall.
Give the
postgresuser the CA certificate and the replication password, for when this server is the standby. Choose the replication password now; you set it on the primary in the next part:sudo install -d -m 0700 -o postgres -g postgres /var/lib/postgresql/.postgresql sudo install -m 0644 -o postgres -g postgres ca.crt /var/lib/postgresql/.postgresql/root.crt sudo touch /var/lib/postgresql/.pgpass sudo chown postgres:postgres /var/lib/postgresql/.pgpass sudo chmod 0600 /var/lib/postgresql/.pgpass echo '*:5432:replication:replicator:REPLICATION-PASSWORD' | sudo tee /var/lib/postgresql/.pgpass >/dev/nullStop PostgreSQL from starting by itself when the server boots:
echo manual | sudo tee /etc/postgresql/18/main/start.conf >/dev/nullWarning
A primary that restarts by itself after a failover is a second primary, with old counters. A Radiator server that connects to it, for example after a restart, accepts OTPs already used on the new primary. The test shows this. With
manual, PostgreSQL starts only when someone runssudo systemctl start postgresql@18-main: always on a standby, and on the old primary only if no failover happened while it was down.sudo systemctl restart postgresql@18-main.
On db1, the first primary: create the roles and the database as in PostgreSQL on a separate server (step 4), then the replication role, with the password from step 4 above:
sudo -u postgres ./yubikey-db.sh -b pg init-pg --admin opsadmin --set-password
sudo -u postgres psql -c '\password opsadmin'
sudo -u postgres psql -c 'CREATE ROLE replicator REPLICATION LOGIN'
sudo -u postgres psql -c '\password replicator'
On db2, make it the standby. This replaces db2's database files with a copy of db1's; the old ones are moved aside:
sudo systemctl stop postgresql@18-main
sudo mv /var/lib/postgresql/18/main /var/lib/postgresql/18/main.$(date +%Y%m%d-%H%M%S)
sudo -u postgres pg_basebackup -h db1.example.com -U replicator -D /var/lib/postgresql/18/main \
-R -X stream -c fast -d 'sslmode=verify-full application_name=db2'
sudo systemctl start postgresql@18-main
-R makes it a standby that follows db1. Check on
db1:
sudo -u postgres psql -c 'SELECT application_name, client_addr, state, sync_state, replay_lag FROM pg_stat_replication'
application_name | client_addr | state | sync_state | replay_lag
------------------+-------------+-----------+------------+-----------------
db2 | 192.0.2.12 | streaming | async | 00:00:00.006296
(1 row)
Once it's streaming, delete the moved-aside folder on db2
(sudo rm -rf /var/lib/postgresql/18/main.2*).
Configure Radiator for two servers
On each Radiator server, install the CA certificate as in PostgreSQL on a separate
server, list both database servers in PGHOST, and write
Radiator's configuration (see Install the configuration):
export PGHOST=db1.example.com,db2.example.com PGUSER=opsadmin PGSSLMODE=verify-full PGSSLROOTCERT=/etc/radiator/pg-ca.crt
./yubikey-db.sh -b pg config | sudo tee /etc/radiator/conf.d/yubikey.conf >/dev/null
sudoedit /etc/radiator/conf.d/yubikey.conf # put Radiator's password on each DBAuth line
sudo systemctl restart radiator
config writes:
DBSource dbi:Pg:dbname=yubikey;host=db1.example.com,db2.example.com;port=5432;sslmode=verify-full;sslrootcert=/etc/radiator/pg-ca.crt;connect_timeout=2;target_session_attrs=read-write
DBUsername radiator
DBAuth RADIATOR-PASSWORD
Timeout 2
ConnectTimeout 5
FailureBackoffTime 2
with two hooks, ConnectionHook and
NoConnectionsHook, and two more clauses,
YubiKeyConnect and the group YubiKeyLocal (see
below).
target_session_attrs=read-writeskips the standby, which only accepts reads. A server that doesn't answer is skipped too.connect_timeout=2gives up on a server that doesn't answer after 2 s.ConnectTimeout(Radiator's limit for the whole connection attempt) must be longer than that times the number of servers.Timeout 2gives up on a query after 2 s. Radiator's default is 60 s, and Radiator answers nothing else while it waits. The hooks below allow 6 s for 10 s after each new connection.FailureBackoffTime 2: when no server accepts writes, each of Radiator's processes rejects logins without trying for 2 s, then tries again, so logins work again within about 2 s of a promotion. Its default is 600 s, which would leave logins failing for 10 minutes after the standby is promoted. A longer time keeps rejecting after the promotion: with 10 s (and without the hooks below), a planned switchover on three sites rejected logins for about 10 s instead of 2 to 5 s. A shorter one tries more often while there is no primary, and each try waits up to 2 s for each server that doesn't answer, so more rejects take that long: in the lab, with the primary powered off, about 16% of rejects took about 3 s (5% with 10 s), and at a site cut off from the other two, about 14% took about 4 s (3% with 10 s).ConnectionHookandNoConnectionsHook, a few lines of Perl: when no server accepts writes but every listed server answers (the primary refuses connections or is shutting down, the others are standbys), as while you switch over, Radiator holds the login and tries again every 0.5 s, for up to 5 s, instead of rejecting it. When a server doesn't answer at all (timeout expiredorNo route to hostin the log), it doesn't hold: that server may be down for a while, and each held login keeps one of Radiator's processes busy. Radiator builds the query for a login before it runs it, and needs a connection to build it, so a process with no connection must connect first: the groupYubiKeyLocalthat the Handlers use runsYubiKeyConnect(it connects, holding the login the same way, then lets the login go on), thenYubiKeySQL. And for 10 s after each new connection (Radiator reconnects only after a failure), a query may take 6 s instead of 2: a primary just promoted saves a change only once a standby streams from it, 1.5 to 3 s in the lab. The hooks change Radiator'sSQLRetries,FailureBackoffTimeandTimeoutwhile it runs, as Radiator 4.30's SQL module allows; the tests check them, so run them again with each new Radiator version.YubiKeyConnectalso gives each login a random ID, whichUpdateQuerypasses to the database with the counters. When the primary stops while a login's counters wait for the standby to confirm them, as in a switchover, they are saved, but Radiator only sees its connection drop. It saves them again on the new primary, and the database accepts that second save as the same login's, instead of refusing it as a replay. Without the ID, such a login got no answer: on three sites, with four logins at a time at each site, 6 of 24 planned switchovers left 1 to 3 logins unanswered (10 of 9,180 in all).
For the admins, put both servers in PGHOST in
~/.profile, as above. One ~/.pgpass line with
* as the host covers both:
*:5432:yubikey:opsadmin:YOUR-PASSWORD. With two servers in
PGHOST, the scripts connect to the one that accepts writes.
If you also use psql directly, add
PGTARGETSESSIONATTRS=read-write to the export
line.
Check replication
On the primary, the query above should show the standby as
streaming. If it returns no row, the standby isn't
receiving changes: look at the standby's log
(/var/log/postgresql/postgresql-18-main.log). On the
standby:
sudo -u postgres psql -c 'SELECT status, sender_host, latest_end_time FROM pg_stat_wal_receiver'
shows streaming and the primary's name. Check this from
your monitoring: a standby that stopped receiving changes is noticed
only at the next failover, when it lacks every login since then.
Fail over
When the primary is down:
Make sure it stays down. Power it off, or if you can still reach it,
sudo systemctl stop postgresql@18-main. A primary that only hangs, or is cut off from you but not from a Radiator server, can come back and keep accepting logins from a Radiator server still connected to it.Promote the standby, on the standby:
sudo pg_ctlcluster 18 main promote sudo -u postgres psql -Atc 'SELECT pg_is_in_recovery()' # f: it accepts writes nowWithin 10 s every Radiator server uses it. Nothing changes on the Radiator servers or for the admins.
Bring the old primary back as the standby
Don't start PostgreSQL on the old primary as it was. Make it a standby of the new primary, with the commands from Set up the two servers ("On db2, make it the standby"), with the names swapped. For example, on db1 after db2 was promoted:
sudo systemctl stop postgresql@18-main
sudo mv /var/lib/postgresql/18/main /var/lib/postgresql/18/main.$(date +%Y%m%d-%H%M%S)
sudo -u postgres pg_basebackup -h db2.example.com -U replicator -D /var/lib/postgresql/18/main \
-R -X stream -c fast -d 'sslmode=verify-full application_name=db1'
sudo systemctl start postgresql@18-main
Then check on db2 that db1 is streaming. The copy takes
seconds for a database of this size. Radiator servers keep using db2,
even those that list db1 first.
Switch over for maintenance
To move the primary to the other server without losing any counter, for example before patching the primary:
- On the primary:
sudo systemctl stop postgresql@18-main. A clean stop sends every change to the standby first. - On the standby:
sudo pg_ctlcluster 18 main promote. - Make the old primary a standby, as above.
Run step 2 within a few seconds of step 1. For 5 s after step 1,
Radiator holds logins and accepts them once the standby is promoted;
after that it rejects them until step 2, and for up to
FailureBackoffTime (2 s) after it.
Automatic failover
The failover in this section is done by a person, who confirms that the primary is down before promoting the standby. A tool that does it automatically must also stop the old primary from coming back as a primary, which needs a third member to tell a dead server from a broken network between two live ones. Three sites does this with Patroni and etcd on three servers. Ubuntu 26.04 also packages pg_auto_failover (with a separate monitor) and repmgr (with a witness), which aren't covered here. Radiator's configuration is the same with any of them, since it follows whichever server accepts writes.
Three sites
With Radiator servers on three sites, each site's server can also
hold a copy of the database, and Patroni moves the
primary to another site by itself when the primary fails. The example
below has three sites, West, East1 and East2, with a 55 ms round trip
between West and the East sites and 4 ms between East1 and East2.
tests/radiator/geo.sh runs everything in this section on
three servers with those delays (see Testing). To
set up three sites with partition mode, anycast, groups and TACACS+ from
one input file instead of step by step, see Turn-key setup.
How three sites work
- Each site's server runs the same three services: etcd, PostgreSQL under Patroni, and Radiator.
- etcd is a small key-value store that the three servers keep identical: a change counts only when two of the three agree. Patroni keeps the name of the primary in it as a lease that the primary renews every 10 s and that expires 30 s after the last renewal.
- Patroni starts and stops PostgreSQL. The server holding the lease is the primary; the other two stream every change from it. When the lease expires, the most up-to-date standby takes it and is promoted. A server that can't reach either of the others can't renew or take the lease, and its Patroni stops its PostgreSQL before the lease runs out. Nobody promotes anything by hand.
- Quorum commit
(
synchronous_mode: quorum): the primary confirms a change only after one of the two standbys has saved it. Every login that Radiator accepted is on two sites, so a failover loses no OTP: the replay window of two servers is closed. East2, 4 ms away, usually confirms first; while East2 is down, West does. And a primary that the standbys no longer follow can't confirm any login. - West becomes the primary only as a last resort: its
failover_priorityis 1, the East sites' is 2. When East2 and West are equally up to date, East2 is promoted. - Every Radiator server lists all three servers
(
PGHOST=east1,east2,west) and uses whichever is the primary, as with two servers. Nothing changes on the Radiator servers when the primary moves. - A former primary rejoins by itself. If it has
changes that no standby confirmed (counter updates for logins it never
answered), Patroni removes them with
pg_rewindand makes it a standby.
The cost is distance: every login at West goes to the primary in the East.
What users see on three sites
Measured by the test:
| Situation | Logins |
|---|---|
| Normal | East1 about 15 ms, East2 about 25 ms, West about 125 ms: two round trips to East1 more. In the test, the East times are mostly disk writes on the primary and on East2. The first login handled by each of Radiator's four processes also opens its connection to PostgreSQL: about 0.5 s at West. |
| West or East2 reboots | No login fails. While East2 is down, West confirms the primary's commits, so logins at the East sites take about 70 ms. |
| East1 (the primary) reboots | Nothing is rejected: while Patroni stops PostgreSQL and East2 takes over, Radiator holds the logins for a few seconds. No OTP is lost. |
| A planned switchover | Nothing is rejected or left unanswered: Radiator holds the logins until East2 is promoted, so the slowest take 3 to 6 s (measured with four logins at a time at each site). No OTP is lost. |
| East1 loses power | Rejected for about 35 s, until the lease expires and East2 is promoted. No OTP is lost. |
| Patroni crashes on the primary | Nothing: systemd starts it again, and it keeps the lease. |
| West is cut off from both East sites | No change at the East sites. At West, every login is rejected
(Yubikey secret not found in database) after 4 to 6 s,
until the network is back; after 10 s when the cut comes within 10 s of
a new connection, whose query may take 6 s (see
ConnectionHook above). West stays a standby and catches up
within a minute afterwards. |
| East1's site is cut off from the others | At East1: no answer at first, then rejected. Elsewhere: rejected for about 45 s, then East2 is the primary. |
| The link between East1 and East2 fails | Logins at East1 and West work; East1's take about 85 ms, because West now confirms them. At East2 they are rejected until the link is back. |
| East2 and West down at once | East1 alone has no majority and stops being the primary: every login is rejected until one of them is back. |
As with two servers, Radiator rejects logins while it can't reach a primary; network devices that try their local accounts only when no RADIUS server answers won't fall back to them. (With anycast, a site that can't log anyone in stops answering instead.) To log users in at a site that can reach no primary, such as West when it is cut off, add partition mode.
Before you start
- Names and addresses. Below:
east1.example.com(192.0.2.31),east2.example.com(198.51.100.32) andwest.example.com(203.0.113.33). Each name must resolve on all three servers, including while the others are down: put them in/etc/hostsif your DNS lives on one site. - Firewall. Between the three servers: TCP 2379 and 2380 (etcd), 5432 (PostgreSQL) and 8008 (Patroni). From the admins' machines: 5432.
- Maintenance one server at a time. Two servers down at once leave no primary.
Set up the three servers
1. On all three servers, install the packages, in addition to Radiator and its modules from Install on the servers:
sudo apt install postgresql postgresql-client libdbd-pg-perl \
etcd-server etcd-client patroni python3-etcd
Patroni needs python3-etcd for etcd, but its package
doesn't install it.
2. Make a CA and one certificate per server. etcd, Patroni and PostgreSQL use them for TLS in both directions, so each certificate has the server's name and address, and is valid for servers and clients. On an admin machine:
openssl req -x509 -newkey rsa:3072 -nodes -days 3650 -subj "/CN=YubiKey DB cluster CA" \
-addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign,cRLSign" \
-keyout ca.key -out ca.crt
mkcert() { # mkcert NAME ADDRESS
openssl req -newkey rsa:3072 -nodes -subj "/CN=$1" \
-addext "subjectAltName=DNS:$1,IP:$2" \
-addext "keyUsage=critical,digitalSignature,keyEncipherment" \
-addext "extendedKeyUsage=serverAuth,clientAuth" -keyout "${1%%.*}.key" -out "${1%%.*}.csr"
openssl x509 -req -in "${1%%.*}.csr" -CA ca.crt -CAkey ca.key -CAcreateserial -days 825 \
-copy_extensions copy -out "${1%%.*}.crt"
}
mkcert east1.example.com 192.0.2.31
mkcert east2.example.com 198.51.100.32
mkcert west.example.com 203.0.113.33
Copy ca.crt and each server's own .crt and
.key to it, and keep ca.key offline. Any
certificate this CA signs can change the cluster's state in etcd, so use
this CA only for these three servers. Patroni refuses a CA certificate
without the keyUsage line (Python 3.13 and later).
3. etcd, on each server. The package started a
single-server etcd; stop it, remove its data, and configure this
server's member. On east1 (on the others, change ETCD_NAME,
the four addresses, and the file names):
sudo systemctl stop etcd
sudo rm -rf /var/lib/etcd/default
sudo install -d -m 0750 -o root -g etcd /etc/etcd/tls
sudo install -m 0644 ca.crt /etc/etcd/tls/ca.crt
sudo install -m 0644 east1.crt /etc/etcd/tls/server.crt
sudo install -m 0640 -o root -g etcd east1.key /etc/etcd/tls/server.key
sudo tee /etc/default/etcd >/dev/null <<'EOF'
ETCD_NAME=east1
ETCD_DATA_DIR=/var/lib/etcd/default
ETCD_LISTEN_PEER_URLS=https://192.0.2.31:2380
ETCD_LISTEN_CLIENT_URLS=https://192.0.2.31:2379
ETCD_INITIAL_ADVERTISE_PEER_URLS=https://192.0.2.31:2380
ETCD_ADVERTISE_CLIENT_URLS=https://192.0.2.31:2379
ETCD_INITIAL_CLUSTER=east1=https://192.0.2.31:2380,east2=https://198.51.100.32:2380,west=https://203.0.113.33:2380
ETCD_INITIAL_CLUSTER_STATE=new
ETCD_INITIAL_CLUSTER_TOKEN=yubikey-db
ETCD_CERT_FILE=/etc/etcd/tls/server.crt
ETCD_KEY_FILE=/etc/etcd/tls/server.key
ETCD_TRUSTED_CA_FILE=/etc/etcd/tls/ca.crt
ETCD_CLIENT_CERT_AUTH=true
ETCD_PEER_CERT_FILE=/etc/etcd/tls/server.crt
ETCD_PEER_KEY_FILE=/etc/etcd/tls/server.key
ETCD_PEER_TRUSTED_CA_FILE=/etc/etcd/tls/ca.crt
ETCD_PEER_CLIENT_CERT_AUTH=true
EOF
Then run sudo systemctl start etcd on all three at about
the same time: it waits until two members have started. Check from any
of them:
sudo etcdctl --cacert /etc/etcd/tls/ca.crt --cert /etc/etcd/tls/server.crt --key /etc/etcd/tls/server.key \
--endpoints https://192.0.2.31:2379 endpoint health --cluster
https://192.0.2.31:2379 is healthy: successfully committed proposal: took = 34.243943ms
https://198.51.100.32:2379 is healthy: successfully committed proposal: took = 39.014402ms
https://203.0.113.33:2379 is healthy: successfully committed proposal: took = 294.70611ms
4. Patroni, on each server. Patroni creates and runs
PostgreSQL from now on, so remove the cluster the
postgresql package created (it holds no data yet), and give
Patroni the certificate:
sudo pg_dropcluster --stop 18 main
sudo install -d -m 0750 -o root -g postgres /etc/patroni/tls
sudo install -m 0644 ca.crt /etc/patroni/tls/ca.crt
sudo install -m 0644 east1.crt /etc/patroni/tls/server.crt
sudo install -m 0640 -o root -g postgres east1.key /etc/patroni/tls/server.key
sudo install -m 0640 -o root -g postgres /dev/null /etc/patroni/18-main.yml
sudoedit /etc/patroni/18-main.yml
with this content on east1. On the others, change name,
the address and name under restapi and
connect_address, and, on West only,
failover_priority: 1. Choose three passwords (the REST
API's, the replication role's and the rewind role's) and use the same
ones on all three servers:
# The Debian helper scripts below take the version and cluster from the scope.
scope: 18-main
namespace: /postgresql-common/
name: east1
restapi:
listen: 192.0.2.31:8008
connect_address: east1.example.com:8008
certfile: /etc/patroni/tls/server.crt
keyfile: /etc/patroni/tls/server.key
cafile: /etc/patroni/tls/ca.crt
authentication:
username: patroni
password: REST-API-PASSWORD
ctl:
cacert: /etc/patroni/tls/ca.crt
certfile: /etc/patroni/tls/server.crt
keyfile: /etc/patroni/tls/server.key
etcd3:
hosts: 192.0.2.31:2379,198.51.100.32:2379,203.0.113.33:2379
protocol: https
cacert: /etc/patroni/tls/ca.crt
cert: /etc/patroni/tls/server.crt
key: /etc/patroni/tls/server.key
bootstrap:
method: pg_createcluster
pg_createcluster:
command: /usr/share/patroni/pg_createcluster_patroni
dcs:
ttl: 30
loop_wait: 10
retry_timeout: 10
synchronous_mode: quorum
synchronous_node_count: 1
postgresql:
use_pg_rewind: true
use_slots: true
parameters:
ssl: 'on'
ssl_cert_file: /etc/patroni/tls/server.crt
ssl_key_file: /etc/patroni/tls/server.key
max_slot_wal_keep_size: 8GB
postgresql:
create_replica_method:
- pg_clonecluster
pg_clonecluster:
command: /usr/share/patroni/pg_clonecluster_patroni
listen: '*:5432'
connect_address: east1.example.com:5432
# Patroni's own connections to this server use its socket: TLS with
# verify-full fails for "localhost".
use_unix_socket: true
use_unix_socket_repl: true
data_dir: /var/lib/postgresql/18/main
bin_dir: /usr/lib/postgresql/18/bin
config_dir: /etc/postgresql/18/main
pgpass: /var/lib/postgresql/18-main.pgpass
authentication:
superuser:
username: postgres
replication:
username: replicator
password: REPLICATION-PASSWORD
sslmode: verify-full
sslrootcert: /etc/patroni/tls/ca.crt
rewind:
username: rewinder
password: REWIND-PASSWORD
sslmode: verify-full
sslrootcert: /etc/patroni/tls/ca.crt
parameters:
unix_socket_directories: /var/run/postgresql
logging_collector: 'on'
log_directory: /var/log/postgresql
log_filename: postgresql-18-main.log
pg_hba:
- local all all peer
- local replication replicator scram-sha-256
- hostssl replication replicator 192.0.2.31/32 scram-sha-256
- hostssl postgres rewinder 192.0.2.31/32 scram-sha-256
- hostssl yubikey radiator 192.0.2.31/32 scram-sha-256
- hostssl yubikey opsadmin 192.0.2.31/32 scram-sha-256
- hostssl replication replicator 198.51.100.32/32 scram-sha-256
- hostssl postgres rewinder 198.51.100.32/32 scram-sha-256
- hostssl yubikey radiator 198.51.100.32/32 scram-sha-256
- hostssl yubikey opsadmin 198.51.100.32/32 scram-sha-256
- hostssl replication replicator 203.0.113.33/32 scram-sha-256
- hostssl postgres rewinder 203.0.113.33/32 scram-sha-256
- hostssl yubikey radiator 203.0.113.33/32 scram-sha-256
- hostssl yubikey opsadmin 203.0.113.33/32 scram-sha-256
watchdog:
mode: off
tags:
failover_priority: 2
What the settings do:
ttl,loop_wait,retry_timeoutare Patroni's defaults: the lease lasts 30 s, the primary renews it every 10 s, and stops being the primary if it can't reach etcd for 10 s. Shorter values fail over sooner after a power loss but risk failovers on a slow network.synchronous_mode: quorumwithsynchronous_node_count: 1: one of the two standbys confirms every commit (see How three sites work).use_slotsandmax_slot_wal_keep_size: the primary keeps the changes a standby hasn't received yet, up to 8 GB, so a standby that was down for days catches up. Make sure the disk has room for it.use_pg_rewind: a former primary is rewound, not copied again.- The
pg_hbalines replacepg_hba.confon every server: replication and rewind between the three servers, andradiatorandopsadminfrom the Radiator servers. Add ahostssl yubikey opsadminline for each other machine the admins work from. watchdog: mode: off. Quorum commit already stops a server that lost the lease from confirming anything; a watchdog device would also restart a server whose Patroni hangs. It isn't covered here.
Debian's patroni@ unit leaves Patroni stopped if it
crashes, with PostgreSQL still running unattended. Have systemd restart
it, and enable it:
sudo mkdir -p /etc/systemd/system/patroni@18-main.service.d
printf '[Service]\nRestart=on-failure\n' | sudo tee /etc/systemd/system/patroni@18-main.service.d/restart.conf >/dev/null
sudo systemctl daemon-reload
sudo systemctl enable patroni@18-main
5. Start Patroni, first on east1, which creates the
database: sudo systemctl start patroni@18-main. When
patronictl (below) shows east1 as Leader,
start it on east2 and west too. Each copies the database from east1 and
starts streaming:
sudo patronictl -c /etc/patroni/18-main.yml list
+ Cluster: 18-main (7696030870670149068) -----------------+----+-------------+-----+------------+-----+----------------------+
| Member | Host | Role | State | TL | Receive LSN | Lag | Replay LSN | Lag | Tags |
+--------+-------------------+----------------+-----------+----+-------------+-----+------------+-----+----------------------+
| east1 | east1.example.com | Leader | running | 1 | | | | | failover_priority: 2 |
| east2 | east2.example.com | Quorum Standby | streaming | 1 | 0/5000060 | 0 | 0/5000060 | 0 | failover_priority: 2 |
| west | west.example.com | Quorum Standby | streaming | 1 | 0/5000060 | 0 | 0/5000060 | 0 | failover_priority: 1 |
+--------+-------------------+----------------+-----------+----+-------------+-----+------------+-----+----------------------+
Quorum Standby means it can confirm the primary's
commits. Patroni has created the replicator and
rewinder roles itself.
6. The yubikey database, on the primary (east1). Create the roles and the database, set Radiator's password, and give the admin a password, as in PostgreSQL on a separate server (step 4):
sudo -u postgres ./yubikey-db.sh -b pg init-pg --admin opsadmin --set-password
sudo -u postgres psql -c '\password opsadmin'
The standbys receive all of it.
7. Radiator, on each server. Install the CA certificate, list all three servers, and write Radiator's configuration, as in Configure Radiator for two servers:
sudo install -m 0644 ca.crt /etc/radiator/pg-ca.crt
export PGHOST=east1.example.com,east2.example.com,west.example.com PGUSER=opsadmin \
PGSSLMODE=verify-full PGSSLROOTCERT=/etc/radiator/pg-ca.crt
./yubikey-db.sh -b pg config | sudo tee /etc/radiator/conf.d/yubikey.conf >/dev/null
sudoedit /etc/radiator/conf.d/yubikey.conf # put Radiator's password on each DBAuth line
sudo systemctl enable --now radiator
config writes
host=east1.example.com,east2.example.com,west.example.com
with target_session_attrs=read-write,
connect_timeout=2 and ConnectTimeout 7. List
the servers in the same order everywhere, the usual primary first:
West's Radiator then connects straight to East1. Put the same
PGHOST in the admins' ~/.profile, with a
~/.pgpass line
*:5432:yubikey:opsadmin:YOUR-PASSWORD.
Run the three sites
Check the cluster with
sudo patronictl -c /etc/patroni/18-main.yml liston any server: oneLeader, twoQuorum Standbymembersstreaming, with a lag near 0. Monitor the same from outside with Patroni's REST API:https://SERVER:8008/primaryanswers 200 only on the primary,/replicaonly on a streaming standby, and/healthon any server whose PostgreSQL runs. For example:curl -s -o /dev/null -w '%{http_code}\n' --cacert ca.crt https://east1.example.com:8008/primary.Reboot one server at a time, and check the cluster in between. A standby can simply be rebooted. Rebooting the primary hands the lease to East2 while Patroni shuts down.
Move the primary with a planned switchover, which loses nothing and rejects logins for a few seconds:
sudo patronictl -c /etc/patroni/18-main.yml switchover --candidate east2 --forceAfter a failover, move it back to East1 the same way once East1 is a
Quorum Standbyagain. Until then East1 is a standby, and if East2 fails too, West becomes the primary.Don't start PostgreSQL yourself (
pg_ctlclusterorpostgresql@18-main) on these servers. Patroni does, as a primary or a standby, and stops it when it must.Copy a standby again from the primary, if it can't catch up (it was down for longer than 8 GB of changes, about a million logins) or its disk was replaced:
sudo patronictl -c /etc/patroni/18-main.yml reinit 18-main west --force --wait
When a site is cut off
- West cut off from both East sites: East1 keeps the lease and East2 confirms its commits; nothing changes at the East sites. West can't reach a primary: its logins are rejected. Its PostgreSQL stays a standby, so there is no second primary. When the network is back, it catches up by itself.
- East1 (the primary) cut off from both others: its Patroni can't renew the lease and stops PostgreSQL within about 10 s. A login on East1 in that moment gets no answer, because no standby confirms its commit. When the lease expires, East2 is promoted, and logins work again everywhere except at East1. When the network is back, East1 is rewound and rejoins as a standby; move the primary back to it.
- The link between East1 and East2 fails, both still reaching West: East1 keeps the lease (etcd still has a majority through West), and West confirms its commits, so logins at East1 take a WAN round trip longer. East2 can't reach the primary: its logins are rejected until the link is back.
With partition mode, a site that reaches neither of the others logs users in from a copy of the keys instead.
Partition mode
With three sites, a site that can reach no
other site can't reach a primary either, so it rejects every login until
the network is back. Partition mode lets that site log
users in from a copy of the keys on its own server while it is cut off,
and copies the counters back to the primary when the network returns. It
is optional: yubikey-partition.sh runs it, on each of the
three servers. tests/radiator/geo.sh tests it after
everything else (see Testing).
How partition mode works
- A copy of the keys on each server. Every 5 minutes,
yubikey-partition.shupdates a SQLite copy of theyubikeystable,/var/lib/yubikey-partition/keys.db, from the server's own PostgreSQL (a standby or the primary), through the local socket. New, changed, disabled and deleted YubiKeys follow; counters only move forward. - Cut off: Radiator uses the copy. Every 5 s, the
script checks whether the other two servers answer, on PostgreSQL's port
or Patroni's. When neither has for 3 checks in a row, the server is cut
off. The script updates the copy once more: a standby that is cut off
still answers reads, with every change up to the cut. Then it points
Radiator's
/etc/radiator/conf.d/yubikey.conf, which is a link, atyubikey-partition.conf(Radiator's clause for the copy) instead ofyubikey-cluster.conf, and restarts Radiator. Logins at that site work again, and their counters are saved in the copy. - If PostgreSQL doesn't answer at that moment, the last copy. When the primary is cut off, Patroni stops its PostgreSQL, which then refuses connections for about a minute before it starts again as a standby. If partition mode begins during that minute, the copy is the one from the last 5-minute update, and it is updated as soon as PostgreSQL answers. (In the test, East1's PostgreSQL still answered when partition mode began.)
- Back: the counters go to the primary. When another server answers and the primary can be reached, twice in a row, the script copies to the primary the counters that moved forward in the copy (only forward, with Radiator's own database login), points the link back at the cluster, restarts Radiator, and copies once more, for logins in between.
- Only when no other site answers. A site that still reaches another site doesn't use its copy: East2 while only its link to East1 is down, or every site while Patroni replaces a primary. They reject, as before.
The risks you accept with partition mode:
- While a site is cut off, an OTP that a user typed at another site can be used once more at the cut-off site, which can't know about it. On a former primary whose PostgreSQL didn't answer when partition mode began, so can an OTP typed anywhere in the last 5 minutes before the cut, until it answers. And an OTP used at the cut-off site can be used once more at another site, until the network is back and its counters have reached the primary.
- A user disabled, a PIN changed or a YubiKey deleted elsewhere while a site is cut off still works at that site until the network is back.
- Each server keeps every AES key in a second place, the copy.
What users see with partition mode
Measured by the test:
| Situation | Logins |
|---|---|
| West is cut off | At West, rejected until West enters partition mode, 13 to 15 s after the cut; logins from its copy work from 20 to 22 s (Radiator restarts in between), and an OTP used at another site just before the cut is a replay there too. At the East sites, nothing changes. 6 to 8 s after the network is back, West uses the primary again, and the OTPs used at West are replays everywhere. |
| East1 (the primary) is cut off | At East1, no answer at first, then rejected, then logins from its copy from 20 to 23 s. Elsewhere, as without partition mode: East2 takes over, and West and East2 stay in normal mode, as they reach each other. 7 to 10 s after the network is back, East1's counters are in the new primary. |
| East2 and West down at once | East1 logs users in from its copy from about 20 s, instead of rejecting until one of them is back. |
| The link between East1 and East2 fails | No change: East2 still reaches West, so it rejects until the link is back. |
With anycast, a cut-off site withdraws while its logins fail, and advertises again once it logs users in from its copy.
Set up partition mode
On each of the three servers, after Set up the three servers:
1. The settings. The package installed the script
and its unit, and install.sh the packages for the copy
(sqlite3 and libdbd-sqlite3-perl):
cd /opt/radiator-yubikey
sudo install -m 0644 yubikey-partition.example.conf /etc/radiator/partition.conf
sudoedit /etc/radiator/partition.conf # PEERS: the other two servers
On west,
PEERS="east1.example.com east2.example.com".
2. Make the copy and Radiator's two clauses:
$ sudo ./yubikey-partition.sh setup
moved yubikey.conf to yubikey-cluster.conf (in /etc/radiator/conf.d)
the copy: /var/lib/yubikey-partition/keys.db, 4 YubiKeys
Radiator's clause: yubikey.conf -> yubikey-cluster.conf; in partition mode -> yubikey-partition.conf
setup moves the clause from step 7 of the three-site
setup to yubikey-cluster.conf, makes the copy (owned by
radiator, which writes the counters in partition mode),
writes yubikey-partition.conf, and makes
yubikey.conf a link to yubikey-cluster.conf.
From now on, change Radiator's clause for PostgreSQL in
yubikey-cluster.conf, never through the link.
3. Start it:
sudo systemctl enable --now yubikey-partition
sudo ./yubikey-partition.sh status
normal mode since 2026-10-13 14:08:21 UTC
peers answering: east1.example.com east2.example.com
copy: 4 YubiKeys, updated 14:08:31
Radiator uses: yubikey-cluster.conf
Run partition mode
See the mode:
sudo ./yubikey-partition.sh statusexits 0 in normal mode and 1 in partition mode, for monitoring.journalctl -u yubikey-partitionlogs each change:started in normal mode: partition mode after 3 checks (15 s) in which none of east1.example.com east2.example.com answers; the copy is updated every 300 s copy: 4 YubiKeys, updated 15:33:34 partition mode: none of east1.example.com east2.example.com answered in 3 checks; Radiator uses this server's copy of the keys (4 YubiKeys, updated just now) normal mode: Radiator uses the cluster again; counters moved forward in the primary for 2 YubiKeysIf the server's PostgreSQL doesn't answer when partition mode begins, the
partition modeline gives the time of the copy and ends withcannot read this server's PostgreSQL: ..., andcopy: updated from this server's PostgreSQL againfollows once it answers. While the copy can't be updated,statussays why, in a linecopy not updated: ....Maintenance stays one server at a time. With two servers down, the third logs users in from its copy, with the risks above, until one of them is back.
Stopping the service leaves the mode as it is. To return to the cluster by hand,
sudo ./yubikey-partition.sh leave: it copies the counters to the primary first, and changes nothing if it can't.
Anycast
With anycast, every Radiator server has the same IPv4 and IPv6 address on its loopback interface, and FRR advertises it to the site's router with BGP. The network devices are given that one address as their RADIUS server (and TACACS+ server) and reach their own site's server. When a server can't log anyone in, it stops advertising the address, and its site's devices reach another site.
yubikey-anycast.sh decides: every 5 s it logs in to the
local Radiator, with a key of its own that works like a YubiKey's, and,
with TACACS+, over TACACS+ too. The
example below adds anycast to the three sites of the previous section.
tests/radiator/geo.sh runs it with an FRR router in front
of the three servers (see Testing).
How anycast works
- One address for all servers:
192.0.2.153and2001:db8:153::153below, in none of your networks. Each server's FRR advertises them to its site's router, as/32and/128, while they are on the server'slo. Each router prefers its own server, so devices use their own site. - The check puts them on
loand takes them off. Every 5 s it logs in to Radiator through 127.0.0.1 asanycast-and the server's name (for exampleanycast-west), with a PIN and an OTP made from its own key, and checks that Radiator listens on the anycast addresses. A login succeeds only if Radiator works, reaches the primary, and the primary saved the OTP's counter. After 2 good checks in a row the addresses go onlo, and FRR advertises them. After 3 failed checks they come off, and FRR withdraws them. - With TACACS+, a check is two logins. With
TACACS_KEYin its settings, each check also logs in over TACACS+, at the same time, as the service accountsvc.anycast-and the server's name, with a password of its own; asks for the session's authorization, as a device does; and checks that Radiator listens on TCP port 49 on the anycast addresses. The check fails if either login fails, so a server whose TACACS+ is broken is withdrawn like one whose RADIUS is. The TACACS+ login goes through the same password and group checks as anyone's, and uses no OTPs. - The check renews its key every year. Like a
YubiKey's, its OTPs have counters that only go up, so a key runs out:
after about 485 days at one check every 5 s. Every 365 days, after a
good check, the running check gives its key a new private ID and AES
key, in the database and in its key file, and its counters start again.
It writes with Radiator's own login, through a function in the database
that can change only the keys of
anycast-*users. Never in partition mode. - Nothing is advertised unless the check says so. The
addresses aren't in the server's network configuration, and FRR's
configuration never changes: FRR advertises what is on
lo, even after it restarts. A server that reboots, or whose check stops or crashes, advertises nothing until its check has passed twice again. Stopping the check (systemctl stop yubikey-anycast) withdraws the addresses at once, which is how to take a server out for maintenance. - BFD tells the router that a server is gone within a second, instead of after BGP's hold time (minutes, by default).
- Radiator listens on the anycast addresses even while they
aren't on
lo. Two kernel settings (ip_nonlocal_bind) allow that, so a Radiator that starts while its server is withdrawn answers as soon as the addresses are back. ItsBindAddresslists them, so the reply to a request sent to an anycast address comes from that address: network devices drop a reply from any other address. - The check's users work nowhere else. Radiator
accepts
anycast-*users (andsvc.anycast-*service accounts) only from 127.0.0.1, and doesn't write their logins to the authentication log.
The check withdraws a server whenever its logins fail, whatever the reason. While no server can log anyone in, for example while Patroni replaces a primary that lost power, every server withdraws after 3 checks, and devices get no answer instead of a reject until a server's check passes again. A network device that falls back to its local accounts when no RADIUS server answers will do so during that time. With partition mode, a site that reaches no other site logs users in from its copy, so its check passes and it keeps advertising.
What devices see with anycast
Measured by the test, with the router preferring West, then East1, then East2:
| Situation | Logins at the anycast address |
|---|---|
| Normal | Answered by West, from the anycast address, over IPv4 and IPv6. |
| West's Radiator stops | No answer for about 13 s (3 failed checks), then East1 answers. West is back 8 or 9 s after Radiator starts again. |
| West's check is stopped (maintenance) | East1 answers at once. |
| West reboots | East1 answers while West is down: West's check stops first, and its BGP session closes. West is back once its check passes twice. |
| West loses power | The router drops West within a second (BFD); East1 answers. |
| West can't reach the primary (cut off from the East sites) | Rejected for about 15 s, then East1 answers, if the devices can still reach it. West is back two checks after it can log in again. |
| The primary (East1) loses power | Rejected at first; West and East2 withdraw 10 to 17 s after the power loss (3 failed checks), and there is no answer until they advertise again, 35 to 45 s after it. |
| East2 and West down at once | East1 withdraws too: no answer until one of them is back. |
Before you start
- The anycast addresses: one IPv4 and one IPv6
address for RADIUS, the same on every server, in none of your networks:
192.0.2.153and2001:db8:153::153below. - Each server's own IPv6 address, for its BGP session
over IPv6:
2001:db8:31::31(east1),2001:db8:32::32(east2) and2001:db8:33::33(west) below. Their routers are192.0.2.1and2001:db8:31::1,198.51.100.1and2001:db8:32::1, and203.0.113.1and2001:db8:33::1. - The cluster stays on IPv4. The three servers' names
in
/etc/hostshave IPv4 addresses only; etcd, Patroni and PostgreSQL keep using those. If a name also resolves to an IPv6 address, PostgreSQL may refuse the connection (no pg_hba.conf entry for host "2001:..."). - AS numbers: the servers use one private AS,
65053below, the same on all three; the routers use yours,64500below. - Firewall: between each server and its router, TCP 179 (BGP) and UDP 3784 (BFD). From the devices to the anycast addresses, UDP 1812, and TCP 49 for TACACS+.
- The devices get the anycast addresses as their
RADIUS server (and TACACS+ server). Each server still needs a
<Client>for each device, with the same secret (and TACACS+ key) on every server.
Set up anycast on each server
1. Install FRR and the settings (the package installed the script and its unit):
sudo apt install frr
cd /opt/radiator-yubikey
sudo install -m 0600 yubikey-anycast.example.conf /etc/radiator/anycast.conf
2. Let Radiator listen on the anycast addresses
while they aren't on lo:
printf 'net.ipv4.ip_nonlocal_bind = 1\nnet.ipv6.ip_nonlocal_bind = 1\n' \
| sudo tee /etc/sysctl.d/60-radiator-anycast.conf
sudo sysctl -p /etc/sysctl.d/60-radiator-anycast.conf
3. Radiator. radiator.example.conf has
the two <Handler> clauses for the check's users,
before the one for everyone else; copy them into an older
radiator.conf. (radiator-groups.example.conf
has them, and a third, for the check's TACACS+ login.) Then set
BindAddress (commented out in the example) to 127.0.0.1,
the server's own addresses and the anycast addresses. On west:
BindAddress 127.0.0.1, 203.0.113.33, ipv6:2001:db8:33::33, 192.0.2.153, ipv6:2001:db8:153::153
sudo systemctl restart radiator
ss -Hlun 'sport = :1812'
ss lists one socket for each of the five addresses (and
so does ss -Hltn 'sport = :49', with TACACS+). Without
BindAddress, Radiator listens on all addresses, and its
replies to requests sent to an anycast address come from the server's
own address.
4. FRR. Turn on its BGP and BFD daemons:
sudo sed -i 's/^bgpd=no/bgpd=yes/; s/^bfdd=no/bfdd=yes/' /etc/frr/daemons
sudoedit /etc/frr/frr.conf
/etc/frr/frr.conf, on west (on the others, change the
name, the router-id and the router's addresses):
frr defaults traditional
hostname west
log syslog informational
!
ip prefix-list RADIUS-ANYCAST seq 10 permit 192.0.2.153/32
ipv6 prefix-list RADIUS-ANYCAST seq 10 permit 2001:db8:153::153/128
!
route-map RADIUS-ANYCAST permit 10
match ip address prefix-list RADIUS-ANYCAST
exit
route-map RADIUS-ANYCAST permit 20
match ipv6 address prefix-list RADIUS-ANYCAST
exit
route-map NOTHING deny 10
exit
!
router bgp 65053
bgp router-id 203.0.113.33
no bgp default ipv4-unicast
neighbor 203.0.113.1 remote-as 64500
neighbor 203.0.113.1 bfd
neighbor 2001:db8:33::1 remote-as 64500
neighbor 2001:db8:33::1 bfd
!
address-family ipv4 unicast
network 192.0.2.153/32
neighbor 203.0.113.1 activate
neighbor 203.0.113.1 route-map NOTHING in
neighbor 203.0.113.1 route-map RADIUS-ANYCAST out
exit-address-family
!
address-family ipv6 unicast
network 2001:db8:153::153/128
neighbor 2001:db8:33::1 activate
neighbor 2001:db8:33::1 route-map NOTHING in
neighbor 2001:db8:33::1 route-map RADIUS-ANYCAST out
exit-address-family
exit
- FRR advertises a
networkonly while the server has a route for it: here, while the address is onlo. (That is FRR'sbgp network import-check, on by default.) - The server sends its router the two anycast routes and nothing else
(
RADIUS-ANYCAST out), and takes none from it (NOTHING in). FRR ignores an eBGP neighbor without these route maps. - One session per address family
(
no bgp default ipv4-unicast): IPv4 routes over the IPv4 session, IPv6 routes over the IPv6 one.
sudo chown frr:frr /etc/frr/frr.conf
sudo chmod 0640 /etc/frr/frr.conf
sudo systemctl restart frr
sudo vtysh -c 'show bgp summary' -c 'show bfd peers brief'
Both sessions should be up (a time under Up/Down) and
both BFD peers up, with 0 routes sent (PfxSnt)
until the check runs.
5. The check. Put the anycast addresses and the
secret of <Client 127.0.0.1> in
/etc/radiator/anycast.conf
(sudoedit /etc/radiator/anycast.conf), and with TACACS+,
its TACACSPLUSKey as TACACS_KEY. Then give the
check its key, in the database, as the admin (with the
PGHOST and ~/.pgpass from Set up the three servers, step
7):
sudo ./yubikey-anycast.sh enroll | ./yubikey-db.sh -b pg import --replace -
enroll makes a key, a PIN and the user
anycast-west, keeps them in
/var/lib/yubikey-anycast/key (mode 0600), and
prints them as one CSV line for import, through the pipe,
never on a command line.
With TACACS+, also give the check its service account, in a group
that may log in to the server itself (device group
localhost). The group and its access are made once, on the
first server:
sudo ./yubikey-anycast.sh enroll-tacacs | ./yubikey-db.sh -b pg service add "svc.anycast-$(hostname -s)" -l 'anycast check'
./yubikey-db.sh -b pg group add anycast-check -l 'yubikey-anycast.sh over TACACS+' # first server only
./yubikey-db.sh -b pg access set anycast-check localhost 1 # first server only
./yubikey-db.sh -b pg member add anycast-check "svc.anycast-$(hostname -s)"
enroll-tacacs makes a password of 32 letters and digits
for svc.anycast-west, keeps it in
/var/lib/yubikey-anycast/tacacs (mode 0600),
and prints it for service add, through the pipe. Then try
the check, and start it:
sudo ./yubikey-anycast.sh check
sudo systemctl enable --now yubikey-anycast
check prints ACCEPT in 148 ms (at West);
with TACACS+, it adds ; TACACS+ PASS and that login's time.
About 10 s after the start, the addresses are on lo, and
the router has the routes:
$ sudo ./yubikey-anycast.sh status
advertised since 2026-10-13 12:26:43 UTC
last check: ACCEPT in 127 ms, 1 s ago
on lo: 192.0.2.153 2001:db8:153::153
OTPs left: 8386814 (485 days at one check every 5 s)
key: next renewal on 2027-10-13
On the server, sudo vtysh -c 'show bgp summary' now
shows 1 route sent (PfxSnt) on each session.
The routers
Each site's router needs:
- A BGP session with its server over IPv4 and over
IPv6, with BFD, in the server's AS (
65053). - A filter: accept only
192.0.2.153/32and2001:db8:153::153/128from the server, at most one route per session, and send it nothing (it would ignore anything anyway). - The anycast routes passed on to the other sites' routers, like your other routes, so that each router has all three servers' routes and prefers its own: a route from a router's own eBGP neighbor already wins over the same route learned from another router; or set a local preference.
- No default route for the anycast addresses: when no server advertises them, a static route with a high distance can reject them, rather than send them to the internet.
In the test, one FRR router stands in for West's and peers with all three servers. For west, its configuration is:
ip prefix-list RADIUS-ANYCAST seq 10 permit 192.0.2.153/32
ipv6 prefix-list RADIUS-ANYCAST seq 10 permit 2001:db8:153::153/128
!
ip route 192.0.2.153/32 reject 250
ipv6 route 2001:db8:153::153/128 reject 250
!
route-map FROM-WEST permit 10
match ip address prefix-list RADIUS-ANYCAST
set local-preference 300
exit
route-map FROM-WEST permit 20
match ipv6 address prefix-list RADIUS-ANYCAST
set local-preference 300
exit
route-map NOTHING deny 10
exit
!
router bgp 64500
no bgp default ipv4-unicast
neighbor 203.0.113.33 remote-as 65053
neighbor 203.0.113.33 bfd
neighbor 2001:db8:33::33 remote-as 65053
neighbor 2001:db8:33::33 bfd
address-family ipv4 unicast
neighbor 203.0.113.33 activate
neighbor 203.0.113.33 route-map FROM-WEST in
neighbor 203.0.113.33 route-map NOTHING out
neighbor 203.0.113.33 maximum-prefix 1
exit-address-family
address-family ipv6 unicast
neighbor 2001:db8:33::33 activate
neighbor 2001:db8:33::33 route-map FROM-WEST in
neighbor 2001:db8:33::33 route-map NOTHING out
neighbor 2001:db8:33::33 maximum-prefix 1
exit-address-family
exit
East1's and East2's servers have the same lines, with local preferences 200 and 100.
Run anycast
See what a server advertises:
sudo ./yubikey-anycast.sh status(it exits 0 only while the addresses are onlo, for monitoring), andjournalctl -u yubikey-anycast, which logs each change and each new kind of check result:started: anycast-west logs in every 5 s; 192.0.2.153 2001:db8:153::153 on lo after 2 good checks, off after 3 failed; 8388095 OTPs left (485 days); key made 2026-10-13, next renewal on 2027-10-13 check: ACCEPT in 580 ms advertising 192.0.2.153 2001:db8:153::153: 2 good checks check: NO REPLY: Connection refused withdrawing 192.0.2.153 2001:db8:153::153: 3 failed checks(The first login of each of Radiator's processes at West takes about 0.5 s, while it connects to the primary. Radiator was stopped before the fourth line.) With TACACS+, each
check:line also has the result of the TACACS+ login, for examplecheck: ACCEPT in 31 ms; TACACS+ FAIL in 12 ms: No such user(see Troubleshooting).On a router,
show bgp ipv4 unicast 192.0.2.153/32lists the servers that advertise it.Before maintenance on a server, stop its check:
sudo systemctl stop yubikey-anycast. Its site's devices move to another site at once. Start it when you are done; the server is back after two good checks. A reboot does this by itself: the check stops before Radiator and FRR do.The check renews its key by itself, 365 days after the key was made (
RENEW_DAYSin/etc/radiator/anycast.conf), or sooner if only 30 days of OTPs are left, after a good check. The log says so:renewing the key (made 2026-10-16) renewed the key of anycast-west (public ID vvcbdefhijkl): a new private ID and AES key; next renewal on 2028-10-15statusends with the date of the next renewal (key: next renewal on 2027-10-16). To renew the key now, stop the check, runsudo ./yubikey-anycast.sh renew, and start it.Renewing needs PostgreSQL: the function
anycast_renew, whichyubikey-db.sh -b pg initadds;psql; and Radiator's login, with its password on theDBAuthline, as in the three-site setup. The check reads it fromyubikey-cluster.conf, or fromyubikey.confwithout partition mode. If a renewal fails, the check tries again an hour later, or 6 checks later if its logins with the old key fail (the database may have the new one). Without these (SQLite, or a database from before 1.10.0 thatinithasn't updated), replace the key by hand: stop the check, run theenrollandchecklines of step 5 again, and start it.enrollkeeps the public ID, soimport --replacereplaces the old key.With partition mode, a renewal waits while the server is in partition mode, and the copy gets the new key at its next sync (within
SYNCseconds, 300 by default). A server that enters partition mode before then withdraws, as its check's logins fail, until it leaves partition mode. After a renewal by hand,sudo ./yubikey-partition.sh synccloses that gap.Taking a server away for good:
sudo systemctl disable --now yubikey-anycast, then remove its check's user:./yubikey-db.sh -b pg delete anycast-west, and with TACACS+, its service account:./yubikey-db.sh -b pg service delete svc.anycast-west.
Groups and TACACS+
With radiator-groups.example.conf instead of
radiator.example.conf, Radiator also takes logins over
TACACS+ (TCP port 49), and checks every login, RADIUS
or TACACS+, against groups in the same database: which
accounts may log in to which devices, at which TACACS+ privilege level,
with which commands, and with which RADIUS reply attributes. It also
takes service accounts: accounts that log in with a
password instead of a YubiKey, for scripts and tools, named
svc.NAME.
How groups work
- A device group is the
Identifierof the devices'<Client>clauses in Radiator, for examplecorefor the core switches. Several devices or subnets can share one. A<Client DEFAULT>clause takes every device that isn't listed, in a device group of its own. - A group holds accounts: YubiKey users and service accounts.
- Access lets a group log in to the devices of a
device group (or to every device,
*), at a TACACS+ privilege level from 0 to 15, with RADIUS reply attributes for RADIUS logins, and, if you want, TACACS+ command rules. - At each login, after the YubiKey or password check, Radiator looks
for the most privileged of the account's groups that may use the
device's group (a named device group before
*). If there is none, the login is rejected. An account counts only while it can log in: it has an active YubiKey, or it is an active service account. - Over TACACS+, the device then asks Radiator to authorize the session (exec), and, if it is set up to, each command. Radiator answers with the group's privilege level and command rules. Each of these requests asks the database again, so a change applies within 2 seconds, even to sessions already open: taking a group's access away, or disabling an account, stops its commands.
- Command rules are tried in order, and the first that matches decides. A command no rule matches is denied. A group with no rules on a device group may run every command there.
enableon a device is a second login, which takes a new OTP. With the privilege level from the group, users don't need it.- The groups are in the database: a change applies on every server at
once, with no Radiator restart. The devices are in Radiator's
configuration: adding one takes
sudo systemctl restart radiator, which also closes the TACACS+ connections that are open (devices open new ones).
Radiator 4.30's TACACS+ has no TLS. Its packets are protected only by the device's key (RFC 8907's MD5-based obfuscation): give each device a long random key, and keep TACACS+ on your management network.
What devices see
| Situation | TACACS+ | RADIUS |
|---|---|---|
| Login, and a group may use the device | Pass; the session gets the group's privilege level | Access-Accept, with the group's reply attributes |
| Login, and none of the account's groups may use the device | Fail | Access-Reject |
| A command the rules deny | Authorization fails | |
| A command, after the account was disabled or its group's access taken away | Authorization fails (within 2 s) | |
| No database server answers | Refused at first (by each of Radiator's processes, for up to a second), then no answer (an error), so the device may try another server. Nothing is allowed. | As for any login (see What users see during a failover) |
Set up groups and TACACS+
These steps were run as written by
tests/radiator/groups.sh (see The automated test), for SQLite and
PostgreSQL. Add -b pg to every yubikey-db.sh
command for PostgreSQL. From the package's folder:
cd /opt/radiator-yubikey
Add the service-account and group tables to the database.
initis safe to re-run, and saysAdded the service-account and group tables:sudo ./yubikey-db.sh init # SQLite ./yubikey-db.sh -b pg init # PostgreSQL, as a member of yubikey_adminInstall the dictionary and the configuration, in place of
radiator.example.conf, and make the folder for the devices:sudo install -m 0644 dictionary.yubikey /etc/radiator/dictionary.yubikey sudo install -m 0640 -o root -g radiator radiator-groups.example.conf /etc/radiator/radiator.conf sudo install -d -m 0750 -o root -g radiator /etc/radiator/clients.d sudoedit /etc/radiator/radiator.confIn the editor, change the
SecretandTACACSPLUSKeyof<Client 127.0.0.1>, and setBindAddressas for anycast if you use it (step 5 there lets the anycast check log in over TACACS+ too).Write the clauses again, as in Install the configuration:
confignow also writesServiceAccountsandGroupAccess../yubikey-db.sh config | sudo tee /etc/radiator/conf.d/yubikey.conf >/dev/null # SQLite ./yubikey-db.sh -b pg config | sudo tee /etc/radiator/conf.d/yubikey.conf >/dev/null # PostgreSQLPut the devices in files in
/etc/radiator/clients.d(mode0640, ownerroot:radiator, since they hold the secrets and keys), one<Client>per device or subnet, for examplesudoedit /etc/radiator/clients.d/core.conf:<Client 192.0.2.10> Identifier core Secret a-long-random-secret-for-radius TACACSPLUSKey another-long-random-key-for-tacacs </Client> <Client 198.51.100.0/24> Identifier access Secret a-long-random-secret-for-radius TACACSPLUSKey another-long-random-key-for-tacacs </Client>openssl rand -base64 32makes a good secret or key.Make the groups, put accounts in them, and give them access:
sudo ./yubikey-db.sh group add netadmins -l 'network team' sudo ./yubikey-db.sh member add netadmins alice carol sudo ./yubikey-db.sh access set netadmins core 15 -r 'cisco-avpair="shell:priv-lvl=15"' sudo ./yubikey-db.sh access set netadmins access 15 sudo ./yubikey-db.sh group add helpdesk sudo ./yubikey-db.sh member add helpdesk bob sudo ./yubikey-db.sh access set helpdesk access 1 sudo ./yubikey-db.sh rule add helpdesk access 'permit service=shell cmd=show'Helpdesk may now only run
showcommands on the access switches, and log in nowhere else. Check what an account gets on a device group:sudo ./yubikey-db.sh access check bob accessbob may log in to devices in access, as a member of helpdesk (access to access), at privilege level 1 TACACS+ rules, the first that matches decides: permit service=shell cmd[*=] {priv-lvl=1} permit service=shell cmd=show RADIUS reply: (none)The first rule is the privilege level, which every access gets.
Check the configuration and restart Radiator, as in Check and start, and allow TCP port 49 from the devices in the firewall, as well as RADIUS.
Test from the server: the RADIUS login with
radpwtstas in Check the whole path, and a TACACS+ login with Radiator's own client, with the key of<Client 127.0.0.1>(the server itself is device grouplocalhost, so give a test group access to it first):sudo ./yubikey-db.sh access set netadmins localhost 1 read -rsp 'PIN:OTP: ' pw; echo perl /opt/radiator/radiator/goodies/tacacsplustest -s 127.0.0.1 -key local-test-key \ -user alice -password "$pw" -noacct -notrace; echo $? sudo ./yubikey-db.sh access delete netadmins localhostType alice's PIN, a colon, and touch her YubiKey.
tacacsplustestprintsOKfor the login andOKfor its authorization (oftelnet host1, which a group with no rules may run), and exits with 0.
Service accounts
A service account logs in with a password, at least 12 characters,
stored as a {crypt} SHA-512 hash like the PINs. Its name is
svc. followed by letters, digits and
. _ @ + -, which is how Radiator tells it from a YubiKey
user. Like any account, it may log in only where its groups allow.
sudo ./yubikey-db.sh service add svc.backup -l 'nightly config backup' # asks for the password twice
sudo ./yubikey-db.sh service add svc.monitor --generate # makes one, and shows it once
sudo ./yubikey-db.sh group add backup
sudo ./yubikey-db.sh member add backup svc.backup
sudo ./yubikey-db.sh access set backup core 15
sudo ./yubikey-db.sh rule add backup core 'permit service=shell cmd=show cmd-arg=running-config'
sudo ./yubikey-db.sh rule add backup core 'permit service=shell cmd=copy'
| Task | Command |
|---|---|
| List service accounts (no passwords) | sudo ./yubikey-db.sh service list |
| Change a password | sudo ./yubikey-db.sh service password svc.backup (or
--generate) |
| Suspend one, or turn it back on | sudo ./yubikey-db.sh service disable svc.backup,
service enable svc.backup |
| Remove one (and take it out of its groups) | sudo ./yubikey-db.sh service delete svc.backup |
Without a terminal, service add and
service password read the password from the first line of
standard input.
Command rules
A rule is permit, permitreplace or
deny, then one pattern for each argument of the TACACS+
request, in order, as Radiator's AuthorizeGroup takes them.
Each pattern is a Perl regular expression that must match the whole
argument. A device asks about show running-config as
service=shell cmd=show cmd-arg=running-config cmd-arg=<cr>;
a rule with fewer patterns than the request has arguments ignores the
rest.
| Rule | Allows or denies |
|---|---|
permit service=shell cmd=show |
Every show command |
permit service=shell cmd=show cmd-arg=running-config |
Only show running-config |
deny service=shell cmd=reload |
reload |
permit service=shell |
Every command |
deny .* |
Everything |
sudo ./yubikey-db.sh rule add netadmins core 'deny service=shell cmd=reload'
sudo ./yubikey-db.sh rule add netadmins core 'permit service=shell'
sudo ./yubikey-db.sh rule list netadmins core
sudo ./yubikey-db.sh rule delete netadmins core 10
sudo ./yubikey-db.sh rule add netadmins core 'permit service=shell cmd=show' --seq 5 # before the first
Rules are numbered 10, 20, 30 and so on; --seq puts one
in between. Once a group has rules on a device group, a command none of
them matches is denied, so the netadmins example above ends with
permit service=shell. A rule may not contain a double
quote, or an octal escape such as \101.
| Task | Command |
|---|---|
| List groups, members and access | sudo ./yubikey-db.sh group list |
| List the access of every group | sudo ./yubikey-db.sh access list |
| Change a privilege level | sudo ./yubikey-db.sh access set helpdesk access 5 |
| Change or remove RADIUS reply attributes | access set helpdesk access 5 -r 'Service-Type=Login-User',
or -r '' |
| Take a group's access away (and its rules) | sudo ./yubikey-db.sh access delete helpdesk access |
| Take an account out of a group | sudo ./yubikey-db.sh member delete helpdesk bob |
| Delete a group (with its members, access and rules) | sudo ./yubikey-db.sh group delete helpdesk |
With partition mode, the copy on each
server also holds the service accounts and groups; the next sync
(SYNC seconds) brings a change to it. A copy made by an
older version gets their tables at the next sync, if
yubikey-db.sh is next to yubikey-partition.sh
(as in the package).
Turn-key setup
yubikey-turnkey does all of Three
sites, Partition mode, Anycast and Groups and
TACACS+ for you, from one input file: you describe the three
servers, their routers, the anycast addresses, the devices and the
groups in site.toml, and it makes a folder for each server,
with a setup.sh that installs and configures everything on
it, and the sheets to hand to whoever runs the routers and the devices.
tests/radiator/turnkey.sh runs it on three fresh servers
(see Testing).
How the turn-key setup works
- One input file, with no secrets in it.
site.tomllists the three servers (the usual primary first, and the one far from the others as the last resort), each one's router or two, the anycast addresses, the admins and their subnets, the devices with their device groups, and the groups to start with.site.example.toml, in the package, is an example with everything it assumes in its comments. - Every secret is made once. The passwords, the
RADIUS secrets and TACACS+ keys, the anycast checks' keys, and the
cluster's CA and certificates are made the first time, in
secrets.tomlnext tosite.toml(mode0600), and taken from there on every later run: the same input always gives the same output, until--rotatemakes new ones (see Rotate passwords and keys). Keepsecrets.toml, and back it up. - A folder per server, made by the tool. In
out/next tosite.toml:east1/,east2/andwest/, each withsetup.sh, the server's settings (server.env,secrets.env), every file it installs (files/, listed with their modes infiles.list), and the database's contents (db/), which the primary'ssetup.shapplies. Next to them,runbook.md(the order to do things in),routers.md,devices.md,admin/(the files for the admins' machines), and with[backup],secrets.toml.cms. setup.shdoes the steps of the sections above, in order: the packages (PostgreSQL, Patroni, etcd and FRR from Ubuntu; Radiator and radiator-yubikey from the packages next to it, and later ones over them: see Upgrade a turn-key setup),/etc/hostsand the kernel settings, the cluster's TLS files, etcd, Patroni, the database, Radiator (radiator-groups.example.confwith the server's addresses and devices), partition mode, FRR and the anycast check, the backups, and the firewall. It ends with a report: the cluster, the anycast check, the BGP sessions.- The three run at the same time. etcd starts only when two servers run it; the first server's Patroni creates the cluster, which the other two copy; the first server makes the database, and the others wait for it. Each says what it waits for.
- It is safe to run again. Each step changes only
what differs from the folder, and restarts a service only when its files
changed. If
setup.shstops, fix the cause and run it again: it goes on where it stopped. It never removes an etcd cluster or a PostgreSQL cluster with databases that it didn't make;--resetstarts a server over. - A lost server comes back from its folder. On a new
server with the same name and addresses, or after
--reseton it alone,setup.shjoins the other two: it adds the server's etcd member again, Patroni copies the database, and the anycast check takes its key and counter from the database (see Rebuild a turn-key server). With two lost,--alonemakes the third the cluster alone first (see Two servers lost). - One command says whether a server works:
setup.sh --statuschecks its services, the cluster, the anycast check, partition mode, BGP, the backups, the firewall, the certificates, the disk and the clock, without changing anything, and sums it up in its first line, for you and for monitoring. - Backups, encrypted for a key that isn't on the
servers (with
[backup]insite.toml, as in the example): each server makes the automatic backups, andyubikey-turnkeywritessecrets.tomlencrypted for the same certificate, which each server keeps too (see Turn-key backups). - The database's contents, each once. The primary's
setup.sh(the first server's, unless it failed over) loads the three anycast checks' keys and TACACS+ service accounts, the groupanycast-check, and the groups insite.toml, each only if it isn't there yet, and removes the anycast checks of servers no longer insite.toml. After that, change the groups withyubikey-db.sh: a later run doesn't undo it, and a key the check has renewed stays. - The devices are in the folders. Radiator's
<Client>clauses come fromsite.toml. To add a device, add it there, runyubikey-turnkeyagain, copy the folders and runsetup.shon each server, which restarts Radiator (and so closes the TACACS+ connections that are open). - The firewall (when on, as by default):
setup.shturns ufw off and loads iptables rules for the whole server, kept byiptables-persistent(with the nft backend). Only these come in: from the devices to the anycast addresses, RADIUS and TACACS+ (from anyone, with the catch-all client); between the three servers, etcd, PostgreSQL and Patroni; from the admin subnets, SSH, PostgreSQL and Patroni's REST API; from the server's routers, BGP and BFD; ICMP, and replies to the server's own connections. It refuses to load them while an SSH connection to the server comes from outside the admin subnets.
Set up three sites with yubikey-turnkey
1. On an admin's machine, with the radiator-yubikey
package (or the release's tarball), Python 3.11 or later and
openssl, make site.toml from the example, and
the backups' key pair next to it (step 1 of Automatic backups says how to keep the
key):
cp /opt/radiator-yubikey/site.example.toml site.toml
editor site.toml
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -aes-256-cbc -out backup.key
openssl req -new -x509 -key backup.key -days 7300 -subj "/CN=yubikey backups" -out backup.crt
site.toml names only the certificate
([backup] certificate = "backup.crt");
yubikey-turnkey refuses a file that holds a private
key.
2. Make the folders, with the packages copied into
each (-p): put Radiator's packages from Radiator Software,
and the release's radiator-yubikey_VERSION_all.deb and
install.sh (the same version as
yubikey-turnkey), in one folder first:
/opt/radiator-yubikey/yubikey-turnkey -p ~/packages site.toml
made secrets.toml, with every password, key and certificate
out/admin/: 3 written
wrote out/devices.md
out/east1/: 38 written
out/east2/: 35 written
wrote out/routers.md
wrote out/runbook.md
wrote out/secrets.toml.cms
out/west/: 35 written
Run it again with no change and it says out: up to date.
It checks the whole input first, and lists every error it finds, for
example
site.toml: device[3].address: '10.2.0.1/24' is not an address or a subnet (with the host bits 0);
it writes nothing until there are none.
Read out/runbook.md: it has the steps below with your
servers' names.
3. Copy each server's folder to it, readable by root
only, and check the server. --check changes nothing:
scp -r out/east1/ root@east1.example.com:/root/turnkey # and east2, west
sudo /root/turnkey/setup.sh --check # on each server
== preflight
ok Ubuntu 26.04
ok host name east1
ok address 192.0.2.31/
ok address 2001:db8:31::31/
ok clock synchronized
ok Radiator's packages are here
ok radiator-yubikey 1.12.1's package and install.sh are here
ok ports
ok 198.51.100.32 reachable
ok 203.0.113.33 reachable
ok SSH connections from the admin subnets only
Nothing to fix.
Each FAIL line says what to fix; a WARN
line (no NTP, little disk) doesn't stop it.
4. Run setup.sh on all three, at about
the same time (within 30 minutes):
sudo /root/turnkey/setup.sh
On east1, which finishes first (the others are still copying the database):
== packages
.. installing postgresql postgresql-client libdbd-pg-perl etcd-server etcd-client patroni python3-etcd frr sqlite3 curl openssl ca-certificates iptables iptables-persistent radiator_4.30-1_all.deb radiator-radius-utilxs_2.4-4.resolute_amd64.deb (apt-get's output is in /var/log/yubikey-turnkey.log)
.. installing radiator-yubikey 1.12.1 with its install.sh (its output is in /var/log/yubikey-turnkey.log)
ok packages: Radiator 4.30-1, radiator-yubikey 1.12.1, PostgreSQL 18+290ubuntu1, Patroni 4.1.0-2, etcd 3.5.16-10, FRR 10.5.1-1ubuntu4.1
== system
.. updated /etc/hosts
.. installed /etc/sysctl.d/60-radiator-anycast.conf
.. installed /etc/yubikey-turnkey/server.env
.. installed /etc/yubikey-turnkey/secrets.env
ok /etc/hosts, kernel settings
== etcd
.. installed /etc/etcd/tls/ca.crt /etc/etcd/tls/server.crt /etc/etcd/tls/server.key /etc/default/etcd
ok etcd: 3 members healthy
== patroni
.. removed the postgresql package's empty cluster 18/main
.. installed /etc/patroni/tls/ca.crt /etc/patroni/tls/server.crt /etc/patroni/tls/server.key /etc/patroni/18-main.yml /etc/systemd/system/patroni@18-main.service.d/restart.conf
ok Patroni: east1=Leader/running west=Replica/stopped
== database
ok database yubikey; roles radiator and opsadmin, with their passwords
ok the anycast checks of east1 east2 west: keys, service accounts, group anycast-check
ok group netadmins
ok group helpdesk
== radiator
.. installed /etc/radiator/dictionary.yubikey
.. installed /etc/radiator/pg-ca.crt /etc/radiator/radiator.conf /etc/radiator/clients.d/core.conf /etc/radiator/clients.d/access.conf /etc/radiator/clients.d/catch-all.conf
.. installed /etc/radiator/conf.d/yubikey.conf
ok Radiator listens on UDP 1812 and TCP 49
== partition
.. installed /etc/radiator/partition.conf
.. moved yubikey.conf to yubikey-cluster.conf (in /etc/radiator/conf.d)
.. the copy: /var/lib/yubikey-partition/keys.db, 3 YubiKeys
.. Radiator's clause: yubikey.conf -> yubikey-cluster.conf; in partition mode -> yubikey-partition.conf
ok partition mode: yubikey-partition runs; Radiator uses yubikey-cluster.conf
== anycast
.. installed /etc/radiator/anycast.conf /var/lib/yubikey-anycast/key /var/lib/yubikey-anycast/counter /var/lib/yubikey-anycast/tacacs /etc/frr/frr.conf
ok the anycast check: ACCEPT in 46 ms; TACACS+ PASS in 50 ms
== backup
.. installed /etc/radiator/backup.crt /etc/radiator/backup.conf /var/backups/yubikey-turnkey-secrets.toml.cms
ok backups: every day at about 03:00, in /var/backups; the newest backup is 0 min old
== firewall
.. installed /etc/iptables/rules.v4 /etc/iptables/rules.v6
ok firewall: 13 IPv4 and 10 IPv6 rules
== report
Patroni: east1=Leader/running east2=Replica/creating replica west=Replica/creating replica
anycast: advertised since 2026-10-16 13:46:05 UTC; last check: ACCEPT in 32 ms; TACACS+ PASS in 18 ms, 0 s ago
BGP: 192.0.2.1 Established, 2001:db8:31::1 Established
east1 is set up.
The other two wait for etcd, for east1's Patroni and for its
database, and say so
(waiting for east1 to create the database (its setup.sh) (40 s)),
then do the same steps. In the test, the three took three and a half
minutes, most of it apt-get. Run it again, on any server,
and every step says ok without installing or restarting
anything.
It logs to /var/log/yubikey-turnkey.log, keeps its state
in /var/lib/yubikey-turnkey, and keeps a copy of
server.env and secrets.env in
/etc/yubikey-turnkey.
5. The routers and the devices, as
out/routers.md and out/devices.md say. Each
server advertises the anycast addresses once its BGP sessions are up and
its check passes.
6. The admins' machines: out/admin/ has
the cluster's CA certificate, PostgreSQL settings for
yubikey-db.sh (pg.env), and a
~/.pgpass line for each admin's login. Then add the
YubiKeys and put the accounts in the groups, as in Groups and TACACS+:
cp out/admin/ca.crt ~/.yubikey-ca.crt
install -m 0600 out/admin/pgpass ~/.pgpass # your own line only, if there are several
. out/admin/pg.env # PGHOST and the rest, and DB_BACKEND=pg
cd /opt/radiator-yubikey
./yubikey-db.sh import users.csv
./yubikey-db.sh member add netadmins alice
Check a turn-key server
setup.sh --status checks the server it runs on, and
changes nothing:
sudo /root/turnkey/setup.sh --status
east1: OK
ok running: etcd patroni@18-main radiator frr yubikey-anycast yubikey-partition
ok etcd: 3 members healthy
ok Patroni: east1 Leader; east2 Quorum Standby, lag 0 MB; west Quorum Standby, lag 0 MB
ok Radiator: listening on UDP 1812 and 1813 and TCP 49, at 127.0.0.1 and the anycast addresses
ok anycast: advertised since 2026-10-16 13:46:05 UTC; last check: ACCEPT in 32 ms; TACACS+ PASS in 18 ms, 2 s ago
ok the check's key: next renewal on 2027-10-16
ok normal mode since 2026-10-16 13:46:12 UTC; the copy for partition mode: 4 YubiKeys, updated 13:51:12
ok BGP: 192.0.2.1 Established, 2001:db8:31::1 Established
ok backups: the newest backup is 5 h old, 7 of 7 kept
ok firewall: 13 IPv4 and 10 IPv6 rules
ok certificates: this server's until 2029-01-19, the CA's until 2036-10-14
ok disk: 41 GiB free in /var/lib
ok clock synchronized
ok setup.sh: finished 2026-10-16 13:46:20 UTC, radiator-yubikey 1.12.1
Each line is ok, WARN (it works, but
something needs you) or FAIL (this server doesn't do its
part):
| Line | WARN |
FAIL |
|---|---|---|
running |
A service isn't running | |
etcd (each member, at its own address) |
Two of the three are healthy | No quorum |
Patroni |
Another server isn't streaming, or is more than 16 MB behind; a server needs a restart; the primary isn't the usual one (after a failover) | No primary; this server isn't streaming from it |
Radiator |
Not listening on RADIUS (UDP 1812, 1813) or TACACS+ (TCP 49), at 127.0.0.1 or an anycast address | |
anycast |
Its last check was more than a minute ago | The addresses aren't advertised |
the check's key |
A renewal is due and hasn't worked | Less than a week of OTPs left |
| partition mode | In partition mode; the copy isn't updated | The service isn't running |
BGP |
A session is down | None is up |
backups |
The newest is more than 26 hours old; the last one failed; the daily timer is off | |
firewall |
The rules aren't loaded | |
certificates |
This server's or the CA's expires within 60 days | This server's has expired |
disk, clock |
Less than 10 GiB free in /var/lib; the clock isn't
synchronized |
Less than 1 GiB free |
setup.sh |
It hasn't finished on this server; the folder changed since it last finished (copied, but not run); the folder is for another version of radiator-yubikey |
The first line is east1: OK, or
east1: WARNING - or east1: CRITICAL -
followed by what's wrong, and it exits with 0, 1 or 2: what monitoring
tools such as Nagios, Icinga or Zabbix expect from a check (3,
UNKNOWN, when it can't check, such as not as root). Run it
on each server: each sees the cluster from where it is. It takes about a
second.
Without the folder, the package's copy does the same, with the copy
of server.env that setup.sh keeps in
/etc/yubikey-turnkey. For a monitoring agent that runs as
its own user (here nagios), allow it that one command:
echo 'nagios ALL=(root) NOPASSWD: /opt/radiator-yubikey/yubikey-turnkey-setup.sh --status' \
| sudo tee /etc/sudoers.d/yubikey-status >/dev/null
sudo chmod 0440 /etc/sudoers.d/yubikey-status
sudo -u nagios sudo -n /opt/radiator-yubikey/yubikey-turnkey-setup.sh --status
Turn-key backups
With [backup] in site.toml,
setup.sh installs the certificate and
/etc/radiator/backup.conf, starts the daily timer and makes
the first backup on each server: the automatic backups, with keep
of them (7 by default: /var/backups/yubikey-db.cms.0 to
.6).
[backup]
certificate = "backup.crt" # next to site.toml; its private key is kept elsewhere
keep = 7
Fetch them and check one now and then as in steps 3 and 4 of Automatic backups.
setup.sh --statuswarns when the newest is more than 26 hours old, the last one failed, or the timer is off.Restore one on the primary (east1, unless it failed over), as in step 5. The anycast checks' keys stay, so all three servers go on advertising.
After losing the whole cluster: set up servers with the same names and addresses, copy the folders and run
setup.shon all three, as on new servers, which makes the database anew with the anycast checks and the groups ofsite.toml, and then restore the last backup on east1.tests/radiator/turnkey.shdoes this after--reseton all three.secrets.toml, encrypted:yubikey-turnkeywritesout/secrets.toml.cms,secrets.tomlencrypted for the same certificate, and each server keeps a copy as/var/backups/yubikey-turnkey-secrets.toml.cms. It is made again only whensecrets.tomlor the certificate changes, so the output stays the same. If you losesecrets.toml, fetch a copy and decrypt it:./yubikey-backup.sh decrypt --key backup.key yubikey-turnkey-secrets.toml.cms secrets.tomlA new key pair: replace
backup.crt, runyubikey-turnkey, copy the folders and runsetup.shon each server, which makes a backup for the new certificate at once. Keep the old key while you keep backups made for it.Without
[backup], the servers make no backups (yubikey-turnkeysays so), andsetup.shturns the timer off where it was on; the backups already made stay.
Rebuild a turn-key server
When a server is lost (its disk, say, or Ubuntu installed anew), set it up again with the same name and addresses while the other two run. In the meantime the other two sites log users in, and the routers send the lost site's devices to them; if it was the primary, Patroni has made East2 the primary within about 30 s.
1. A server with the same name and addresses: Ubuntu
26.04 LTS, with its host name and its IPv4 and IPv6 addresses as in
site.toml (step 1 of the runbook). For a server whose disk
is fine but whose etcd or PostgreSQL is broken, start it over in place
instead: sudo /root/turnkey/setup.sh --reset NAME.
2. Its folder, as yubikey-turnkey last
made it, with the packages: copy it as in step 2 of the runbook.
3. Run setup.sh on it alone:
sudo /root/turnkey/setup.sh
It sets up the server as a new one, except:
- etcd: the cluster still has the server's member
from before, without its data.
setup.shremoves that member and adds it again, through the other two (both must run etcd), as a learner, which counts for no quorum until it has caught up; the server's etcd joins the cluster that runs, andsetup.shthen makes it a voting member. - Patroni copies the database from the primary, as a standby: the first server's too, which then isn't the primary (step 5).
- The database is left as it is: the first server
loads nothing from
db/again. - The anycast check takes its key and its counter from the database, so it goes on with a key it renewed since (the folder has the one from before), and its first OTP is above the last one the database saw.
On West, whose check had renewed its key (in the lab):
== etcd
.. installed /etc/etcd/tls/ca.crt /etc/etcd/tls/server.crt /etc/etcd/tls/server.key /etc/default/etcd
.. etcd: removed this server's member from before (512d880f0d6ebf0e), to add it again
.. etcd: added this server's member (9ac3382badfb3812), as a learner until it has caught up
.. etcd: promoted this server's member, which has caught up
ok etcd: joined the cluster; 3 members healthy
== patroni
.. removed the postgresql package's empty cluster 18/main
.. installed /etc/patroni/tls/ca.crt /etc/patroni/tls/server.crt /etc/patroni/tls/server.key /etc/patroni/18-main.yml /etc/systemd/system/patroni@18-main.service.d/restart.conf
.. waiting for this server to copy the database and stream from the primary (11 s)
ok Patroni: east1=Leader/running east2=Quorum_Standby/streaming west=Replica/streaming
== database
ok the database has this server's anycast check
...
== anycast
.. installed /var/lib/yubikey-anycast/key
.. the anycast check's key from the database: renewed on 2026-10-17
.. installed /var/lib/yubikey-anycast/counter
.. installed /etc/radiator/anycast.conf /var/lib/yubikey-anycast/tacacs /etc/frr/frr.conf
ok the anycast check: ACCEPT in 73 ms; TACACS+ PASS in 54 ms
On the first server, Patroni says
the cluster exists, with east2 as its primary: this server copies it, as a standby,
and the database
this server's PostgreSQL isn't the primary now: the database was made before; left as it is.
4. Check all three with
setup.sh --status: each says OK.
5. The first server: it is a standby now, and its
--status warns that the primary is East2. Move the primary
back when it suits you (logins are held for a few seconds):
sudo patronictl -c /etc/patroni/18-main.yml switchover --candidate east1 --force
Two servers down at once: etcd on the third has no quorum, so no member can be removed or added, and
setup.shstops at etcd, before starting it:setup.sh: error: etcd: the cluster has this server's member from before, and fewer than two servers run etcd, which adding it again needs. Start etcd on the other two (see "Rebuild a turn-key server" in the guide)If the other server is only down, or its etcd stopped, start it and run
setup.shagain. If it is lost too, see Two servers lost.All three servers' etcd down (a power cut at every site, say): nothing answers, so
setup.shstarts etcd as in a new cluster, and waits; once the other two run etcd again, it joins them. A server rebuilt before, whose member was added again then, starts as the member it was at first, which the others removed: they refuse it, andsetup.shsaysetcd: the others refused this server's etcd, started as in a new cluster: they have its member under another ID, and adds its member again.New names or addresses: see Replace a turn-key server.
tests/radiator/turnkey.shrebuilds West, whose check had renewed its key, and East1, lost while it was the primary, on new servers; starts East2 over in place with--reset; and starts West over while East2's etcd is stopped, and while all three's is.
Two servers lost
When two servers are lost at once, for good, the third goes on alone:
partition mode logs users in there from its copy (see What users see with partition
mode), and the routers send every device to it. (For the first
seconds no login is answered: the third's anycast check fails until
partition mode is on, and the third may withdraw its addresses until the
check has passed again.) But its etcd has no quorum, so its PostgreSQL
isn't the primary, and nothing in the database can change.
setup.sh --alone makes it the cluster alone; then you set
up the other two again.
Only when both are lost for good. If they still run,
cut off from the third, they are still a cluster, with a primary, and
--alone makes a second one: there are then logins and
changes on both sides, which nothing brings together again.
--alone refuses while either answers on etcd's,
PostgreSQL's or Patroni's ports, but from the third a cut network looks
the same as a lost server: make sure at the sites.
1. On the third server (here West):
sudo /root/turnkey/setup.sh --alone
It asks, and then:
- etcd starts once with
--force-new-cluster, which keeps its data and makes it the only member, and then as before. - Patroni doesn't make its PostgreSQL the primary by
itself: in quorum mode, without the other standby, it can't tell that
this one has every transaction. Once the lost primary's leader key has
expired (in up to 30 s),
setup.shfails over to it. A transaction that only the other lost standby had confirmed is lost: an OTP of the last second or so may work once more. - Partition mode stays on until one of the other two answers again.
== alone: west
ok the other two answer on none of etcd's, PostgreSQL's and Patroni's ports
This makes west the cluster alone: its etcd forgets the other two servers, and its
PostgreSQL becomes the primary. Only when both are lost for good: if they still run, cut off
from here, they go on as a cluster of their own, and there are two primaries.
Make west the cluster alone? Type yes: yes
ok etcd: a cluster of one, west
.. waiting for the lost primary's leader key to expire (12 s)
ok Patroni: west's PostgreSQL is the primary
.. partition mode stays on until one of the other two answers again; then the counters of the logins in between go into the database
ok west is the cluster alone. Now set up the other two again: new servers with the same names and addresses, their folders, and setup.sh on both (see "Two servers lost" in the guide).
In the lab it took about a minute. --yes doesn't ask;
run again, it finds nothing to do.
2. The other two: set them up again as in steps 1 to
3 of Rebuild a turn-key server,
and run setup.sh on both (at the same time is fine). Each
adds its etcd member as a learner, one after the other, and copies the
database from the third. Once their Patroni answers, the third leaves
partition mode, and moves the counters of the logins in between into the
database, before their anycast checks advertise.
3. Check all three with
setup.sh --status, and move the primary back to the first
server, as in step 5 there.
- While it is alone, the third's
setup.sh --statuswarns about etcd and Patroni, and its backups miss the counters of the logins in partition mode, which are only in its copy. tests/radiator/turnkey.shloses East1 and East2, logs in through West from its copy, runs--aloneon West (refused while the two run, without a terminal and when not answered yes; then answered yes, and again with--yes), sets up the two again at the same time, and checks that the OTP used on West alone is refused after.
Replace a turn-key server
To move a server to new addresses, or to replace it with one of a new name (when a site moves, say), the other two take the new one in: the old server's etcd member goes, and with a new name, its anycast check and service account too; the new server joins as a rebuilt one does.
1. The old server, unless it is lost: if it is the primary, move the primary to another server first, then start it over, which stops its services, and switch it off:
sudo patronictl -c /etc/patroni/18-main.yml switchover --candidate east2 --force # if it is the primary
sudo /root/turnkey/setup.sh --reset NAME
2. site.toml: the server's new name and
FQDN, or its new addresses (and its routers, if they change), then run
yubikey-turnkey again. It makes the new server's
certificate, and for a new name its anycast key, and says why:
yubikey-turnkey: west has new addresses, and so a new certificate (a server replaced: see the guide's "Replace a turn-key server")
For a new name it says that the old one's anycast key and certificate
are gone from secrets.toml. Give the routers' admins the
new routers.md: the server's routers peer with its new
addresses.
3. The two that stay: copy their folders and run
setup.sh on each. Each lets the new server through its
firewall, and updates /etc/hosts and Patroni's settings
(PostgreSQL's pg_hba.conf), and for a new name Radiator's
list of database servers and partition mode's peers (Radiator restarts,
so logins at that site wait a few seconds). Their etcd needs only quorum
now, and isn't restarted for the new member list:
ok etcd: 2 of 3 members healthy (not west), with quorum
For a new name, the primary's setup.sh takes the old
server's anycast check out of the database and puts the new one's
in:
.. removed the anycast check of NAME, which isn't one of the servers now
.. removed the service account svc.anycast-NAME
4. The new server: steps 1 to 3 of the runbook, as
for a rebuilt one. Its setup.sh removes the old server's
etcd member (it stops if that one still answers: stop the old server
first), and adds its own:
== etcd
.. installed /etc/etcd/tls/ca.crt /etc/etcd/tls/server.crt /etc/etcd/tls/server.key /etc/default/etcd
.. etcd: removed the member of west (https://172.31.99.13:2380), which isn't one of the three servers now
.. etcd: added this server's member (5c25fbdaec985409), as a learner until it has caught up
.. etcd: promoted this server's member, which has caught up
ok etcd: joined the cluster; 3 members healthy
With a new name, its anycast check's key comes from the database, where the primary put it in step 3.
5. Check all three with
setup.sh --status. If the new server is the first in
site.toml, it is a standby: move the primary to it, as in
step 5 of Rebuild a turn-key
server.
- Until the two that stay have run
setup.sh, their firewall drops the new server's connections, and itssetup.sh --checkwarns:no answer from ADDRESS on TCP 2380 within 3 s: the servers must reach each other on TCP 2379, 2380, 5432 and 8008. tests/radiator/turnkey.shmoves West to new addresses, after--reseton it, and replaces East1, lost while it was the primary, with a server of a new name and addresses, which then becomes the primary.
Rotate passwords and keys
yubikey-turnkey --rotate makes new passwords, secrets
and keys in secrets.toml, and setup.sh puts
them in place in an order that keeps logins and replication working:
--rotate |
New | Used by |
|---|---|---|
radiator |
Radiator's database password | Radiator on each server, and the role radiator |
patroni |
Patroni's REST API password, and the passwords of the roles
replicator and rewinder |
Patroni on each server (patronictl, replication,
pg_rewind) |
admins, admin:LOGIN |
The admins' database passwords | The admins' ~/.pgpass, from
admin/pgpass |
localhost |
The RADIUS secret and TACACS+ key of each server's own client,
127.0.0.1 |
Radiator and the anycast check on each server |
catch-all, devices,
device:ADDRESS |
The devices' RADIUS secrets and TACACS+ keys | Radiator, and the devices (devices.md) |
Name several with commas, or give --rotate again. Not
rotated: the anycast checks' PINs and TACACS+ passwords (Radiator takes
their logins from 127.0.0.1 only, and each check renews its
key by itself, every RENEW_DAYS days, or now with
yubikey-anycast.sh renew), the servers' certificates
(--renew-certs, in Change a turn-key setup), and the
CA.
1. Make them:
yubikey-turnkey --rotate radiator,patroni,admins site.toml
secrets.toml: new radiator's password, Patroni's REST API password, replicator's password, rewinder's password, opsadmin's password
...
yubikey-turnkey: new database passwords (radiator, replicator, rewinder and opsadmin): copy the folders, and run setup.sh on the primary first, which gives them to the roles; on the other two, setup.sh waits until the primary has (see the runbook, and the guide's "Rotate passwords and keys")
yubikey-turnkey: give opsadmin the new line of admin/pgpass, for ~/.pgpass
2. The primary first: copy the folders, and run
setup.sh on the primary. It gives the database's roles
their new passwords (Patroni sets replicator's and
rewinder's only when it makes the cluster, so
setup.sh does it):
== database
.. set radiator's new password
.. set replicator's new password
.. set rewinder's new password
.. set opsadmin's new password
ok database yubikey; roles radiator and opsadmin, with their passwords
Connections made before keep working, so the other two's Radiator and replication go on with the old passwords until they change.
3. The other two: run setup.sh on each.
Before Patroni or Radiator use a new password, it checks in its own copy
of the database that the primary has given it to the role. Run on one of
them before the primary, it waits, and goes on once the primary's
setup.sh has run:
== patroni
.. installed /etc/patroni/18-main.yml
.. waiting for the primary, east1, to have the new passwords of replicator and rewinder (run setup.sh there first) (10 s)
ok Patroni: east1=Leader/running east2=Quorum_Standby/streaming west=Quorum_Standby/streaming
4. The admins: give each one their new line of
admin/pgpass, for ~/.pgpass.
setup.sh sets an admin's password only when
secrets.toml has a new one, so a password an admin changed
(with ALTER ROLE or psql's \password) stays
until the next --rotate admin:LOGIN. The role's comment
names the one it set last.
5. Devices: --rotate device:ADDRESS (or
devices, catch-all), setup.sh on
the three servers, in any order, then the new RADIUS secret and TACACS+
key on the device, from devices.md. Radiator 4.30 takes one
secret and one key per device, so the device's logins fail from the
moment its server has the new ones until the device has them too: do it
one device at a time, with the device's admin ready, or in a maintenance
window.
localhost needs no order either: Radiator and the
anycast check on each server change together. The check starts again,
and its server withdraws the anycast addresses for about 10 seconds
while the other two answer.
- Logins go on.
tests/radiator/turnkey.shlogs in over TACACS+ at the anycast address every second while it rotatesradiator,patroni,adminsandlocalhost, with West run first: no login failed, or one, 1 second, when Radiator restarted on the server the login went to. It cuts the standbys' replication connections after the change, which connect again with the new password; moves the primary withpatronictland back; and checks the admin's old password refused, the new one working, an admin's own password kept until--rotate admin:LOGIN, and a device's old TACACS+ key refused once the servers have the new one.
Upgrade a turn-key setup
One server at a time, while the other two answer:
setup.sh restarts only what changed on the server it runs
on.
A new release of radiator-yubikey. Read its
CHANGELOG first: new settings in site.toml stay off until
you add them. On the admins' machine, run the new release's
yubikey-turnkey with the same site.toml and
secrets.toml, and its package in the packages folder
(-p, with Radiator's packages as before). It keeps every
secret, and makes what the new release needs, in a line like this one
(1.11.0 to 1.12.0):
secrets.toml: new replicator's SCRAM verifier, rewinder's SCRAM verifier
From 1.12.0, there is nothing new to make, and no such line.
Copy the folders, and run setup.sh on the primary first
(its yubikey-db.sh init brings the database up to date
before the other servers' Radiator needs it), then on the other two, one
at a time. Each installs the new package with its
install.sh:
== packages
.. installing radiator-yubikey 1.12.1 with its install.sh (its output is in /var/log/yubikey-turnkey.log)
Then setup.sh --status on each.
A newer Radiator (a patch release from Radiator
Software): its packages instead of the old ones in the packages folder,
yubikey-turnkey -p again, copy the folders, and
setup.sh on each server, one at a time. It installs the
newer packages over the old ones (never older ones), keeps the server's
radiator.conf (the package's new sample is beside it, as
radiator.conf.dpkg-dist), and restarts Radiator:
.. radiator 4.30-1: replaced by the folder's 4.30-1+lab1
(That is the test's: Radiator 4.30-1 again, as 4.30-1+lab1.) A folder
copied over the one before keeps the old packages too;
setup.sh takes the newest of each.
Ubuntu's updates. Ubuntu Server comes with
unattended-upgrades, which installs the security updates by
itself every day, from 06:00 to an hour later, on each server on its
own; and with needrestart, which after each
apt run restarts the services that use what it replaced.
setup.sh leaves both as Ubuntu has them. So after an update
of PostgreSQL (or Python), needrestart restarts Patroni,
which restarts PostgreSQL: on a standby, it streams again a few seconds
later; on the primary, Patroni stops PostgreSQL and another server takes
over, and the primary is then not the first server
(setup.sh --status warns) until you move it back. Logins go
on: in the test, a TACACS+ login every second missed none, through the
planned updates and the failover. (setup.sh's own
apt-get runs with NEEDRESTART_MODE=l, so that
needrestart only lists: setup.sh restarts what
it installs.)
Planned updates, and a new kernel (which needs a reboot), one server at a time, the primary last:
On each standby:
sudo apt-get update && sudo apt-get upgrade sudo /root/turnkey/setup.sh --status # OK once it streams againIf
/run/reboot-requiredexists, reboot it, and run--statusagain.Move the primary to an updated standby:
sudo patronictl -c /etc/patroni/18-main.yml switchover --candidate east2 --forceUpdate the old primary as in step 1.
Move the primary back to the first server.
Not covered yet: PostgreSQL's next major version, Ubuntu's next release, and a new CA.
tests/radiator/upgrade.shsets up three servers with 1.12.0 (from its git tag), upgrades them to this version, then to a newer Radiator, and then updates PostgreSQL on each (withneedrestart) as above, and on the primary without the switchover, as the automatic updates do, with a TACACS+ login every second: in three runs, one missed or none in each upgrade (radiator-yubikey's, Radiator's), for a second or two while Radiator restarted; none in the updates or the failover.
Change a turn-key setup
- Devices, groups to start with, admin subnets, partition
mode, the firewall: change
site.toml, runyubikey-turnkeyagain (it prints what it wrote, and--checkwhat it would write), copy the folders again (scp -r out/east1/. root@east1.example.com:/root/turnkey/), and runsetup.shon each server. A device that is gone fromsite.tomlkeeps its secret insecrets.toml, in case it comes back. - A device's existing secret or key: put it in
secrets.toml(8 to 128 printable characters, without spaces, quotes,\,#,%,`or|), and runyubikey-turnkey. Remove a value to have a new one made. New passwords, secrets and keys: see Rotate passwords and keys. - New certificates for the servers (they last 825
days; the tool warns 60 days before):
yubikey-turnkey --renew-certs site.toml, then copy the folders and runsetup.shon each server, one at a time. - A server's name or addresses: see Replace a turn-key server. A server lost or broken is set up again with the same ones: see Rebuild a turn-key server.
- Start over:
sudo /root/turnkey/setup.sh --reset NAME(NAME: the server's host name) stops the services and removes the server's etcd data, PostgreSQL cluster, anycast key andsetup.sh's state (the backups in/var/backupsstay). Then runsetup.sh: on this server alone, while the other two run, it joins them again (see Rebuild a turn-key server); after--reseton all three,setup.shon all three makes the database anew, without the YubiKeys, which a backup can bring back.
Security
- The database holds every user's AES key. Anyone who
can read it can copy the YubiKeys. The SQLite file is mode
0640, readable by root andradiatoronly. In PostgreSQL, onlyyubikey_adminmembers and theradiatorrole can read the table. - Exports, CSV files and backups hold every AES key in clear
text. Encrypt them (
export - | gpg -e ...), keep CSV files at mode0600, never commit them or attach them to tickets, and delete them withshred -u. The script warns about a CSV file that all users can read. - A filled-in copy of
yubikey-db-user.example.shholds an AES key. Keep it at mode0700, outside the repository, andshred -uit when done. - Replace the package's sample
radiator.conf(it accepts every login from 127.0.0.1) and give every RADIUS client its own long random secret. Trace 4writes every user's AES key and PIN hash to Radiator's log (theAuthSQLYUBIKEY read:lines). Use it only on a test server with test keys, and delete the log afterwards.radiator.example.confusesTrace 3.- Keep
Require2Factorunless you mean OTP-only logins for everyone: without it, the PIN is optional. /etc/radiator/conf.d/yubikey.confholds Radiator's database password for a remote PostgreSQL: mode0640, ownerroot:radiator.~/.pgpassmust be mode0600.- Remote PostgreSQL:
hostssllines for single addresses inpg_hba.conf,scram-sha-256passwords, andsslmode=verify-fullon the clients, so a server without your CA's certificate is refused. - A standby holds every AES key too, and the
replicatorrole can copy the whole database: protect both database servers alike, and keep~postgres/.pgpassat mode0600. - Three sites: each server holds every AES key, so
protect all three alike. Any certificate signed by the cluster's CA can
change which server is the primary, through etcd: use that CA only for
these three servers and keep
ca.keyoffline./etc/patroni/18-main.ymlholds the replication, rewind and REST API passwords: mode0640, ownerroot:postgres. Patroni's REST API answers health checks without a password but takes commands, such as a switchover, only with it; open port 8008 only to the three servers and your monitoring. - Partition mode: the copy,
/var/lib/yubikey-partition/keys.db, holds every AES key, like the database: mode0640, owned byradiator.yubikey-cluster.confholds Radiator's database password (mode0640, ownerroot:radiator); partition mode uses that login to copy counters back. See the risks of using the copy. - Anycast:
/var/lib/yubikey-anycast/keyholds a working login, the check's PIN and AES key, and/var/lib/yubikey-anycast/tacacsthe password of its TACACS+ service account: mode0600, root only. Radiator accepts the check's users (anycast-*, andsvc.anycast-*inradiator-groups.example.conf) only from 127.0.0.1, because of the<Handler>clauses for them; keep those before the ones for everyone else./etc/radiator/anycast.confholds the secret and TACACS+ key of<Client 127.0.0.1>: mode0600. Radiator's PostgreSQL role may run the functionanycast_renew, which gives the key of ananycast-*user a new private ID and AES key (and nothing else); since that role can already read every AES key, it lets it do little more than log in as the check from a Radiator server. Each router accepts only the two anycast routes from its server. If your routers require BGP passwords, FRR sets them withneighbor ADDRESS password(the test doesn't use them). - Groups and TACACS+: the files in
/etc/radiator/clients.dhold every device's RADIUS secret and TACACS+ key: mode0640, ownerroot:radiator. Radiator 4.30's TACACS+ has no TLS, only the key's obfuscation: long random keys, and TACACS+ only on your management network. Service accounts' passwords are salted SHA-512{crypt}hashes; in PostgreSQL, Radiator's role can read only those hashes (with the names andactive) and the viewaccess_replies, and change nothing in the group tables. Keep the twoAuthorize-OnlyHandlers ofradiator-groups.example.confbefore the others: the first works around Radiator answering a refused TACACS+ authorization with a packet devices take for an error (which some then treat as allowed), the second refusesAuthorize-Onlyfrom RADIUS clients, which would otherwise get an account's reply attributes without a password. A RADIUS client can't sendYubiKey-Via, which marks TACACS+ requests (its number can't be carried in a RADIUS packet). - Turn-key setup:
secrets.tomlholds every password and key of the deployment and the cluster's CA key, andout/copies of them: keep both where only you can read them (the tool makes them mode0600and0700), back upsecrets.toml, and don't senddevices.md(every device's secret and key) further than needed. Each server's folder holds only what that server needs; the first server's also holds the anycast checks' keys of all three, to load them.setup.shkeeps a copy of the server's settings and secrets in/etc/yubikey-turnkey(root only). The admins' passwords reach the servers only as SCRAM verifiers. Its firewall leaves outgoing traffic open. - Automatic backups hold every AES key, encrypted for
the backups' certificate: only its private key decrypts them, so keep it
off the servers, in two places, with its passphrase apart from it. The
servers keep them readable by the group
adm, for the admins to fetch. A decrypted dump holds every AES key in clear:shred -uit after a restore.restorebrings back what the backup had, also YubiKeys or accounts disabled since: it lists them (enabled again).secrets.toml.cmsis encrypted for the same certificate. - The scripts pass AES keys and PINs to
sqlite3andpsqlthrough their input, never on a command line, and quote every value. PINs are stored as salted SHA-512{crypt}hashes (-H clearstores plain text, if something else must read it).
Troubleshooting
Start with verify for the user: if it accepts the login
and Radiator doesn't, the problem is in Radiator's configuration or the
network.
| Symptom | Cause and fix |
|---|---|
radpwtst prints No reply |
Radiator isn't running (systemctl status radiator), the
port is wrong (radpwtst defaults to 1645: add
-auth_port 1812), the client's address isn't in a
<Client> block, or the secret is wrong. A wrong
secret is dropped without a log line. |
Radiator logs Replay attack detected |
That OTP, or a newer one from the YubiKey, was already used. Touch the YubiKey again. |
Radiator logs Bad static password |
Wrong PIN, no PIN typed with Require2Factor set, or the
user has no stored PIN while Require2Factor is set
(Radiator always rejects an empty stored PIN). |
Radiator logs Bad Yubikey password |
The OTP doesn't decrypt with the stored AES key. Check with
verify, and load the key from the ykman output
again with add --update. |
Radiator logs Bad Yubikey Secret-Id |
The stored private ID isn't the YubiKey's. Load it again with
add --update. |
Radiator logs Yubikey is not active |
The YubiKey is disabled: enable it. |
Radiator logs Yubikey secret not found in database |
No row has that username and public ID, the public ID isn't 12
characters, or Radiator can't reach the database (look for
Could not connect above it in
radiator.log). |
Radiator logs Could not connect to SQL database |
Check DBSource, DBAuth, the network and
pg_hba.conf. With the configuration from
config, Radiator tries again after 2 seconds
(FailureBackoffTime); a configuration without that line
waits 600 seconds, so after fixing it,
sudo systemctl restart radiator. |
Radiator logs
Could not connect ... session is read-only |
That server is a standby. Normal for the standby when both servers are listed. If every server says so, no server is the primary: promote the standby (see Fail over). |
Radiator logs
No database server accepts writes: trying again in 0.5 s |
No server accepts writes, but they all answer: the primary is being switched over or restarted. Radiator holds logins for up to 5 s. If it goes on, no server is the primary: promote the standby (see Fail over), or check Patroni (three sites). |
Radiator logs
Could not connect to any SQL database |
No listed server accepts writes. Logins are rejected (not ignored)
until one does; Radiator tries again every 2 s. A configuration without
config's hooks adds Request is ignored. |
Radiator logs counter violation and
Access ignored ... Database update failed |
The same OTP arrived twice at the same moment (a retransmission, two
Radiator servers, or a replay attempt). The first was accepted; the
second gets no answer. If it happens without a reason, find where the
second request came from. Right after a
do failed for 'update yubikeys ... line about a lost
connection (in a switchover, for example), the database refused
Radiator's second save of the same login: write the clause again with
config and run init (see After upgrading the
scripts). |
journalctl -u yubikey-partition shows
copy: cannot read this server's PostgreSQL |
Normal while Patroni stops or starts PostgreSQL, for example on a
former primary that is cut off; the copy is updated again when it runs.
Otherwise check sudo -u postgres psql -d yubikey. |
| Partition mode stays on after the network is back | The primary can't be reached with Radiator's login in
yubikey-cluster.conf (its DBSource,
DBAuth, pg_hba.conf). See
journalctl -u yubikey-partition;
sudo ./yubikey-partition.sh leave shows the error. |
Radiator logs attempt to write a readonly database in
partition mode |
radiator must own the copy and its folder:
sudo chown -R radiator:radiator /var/lib/yubikey-partition. |
yubikey-anycast.sh check prints
ACCEPT ..., but Radiator does not listen on 192.0.2.153 ... |
BindAddress doesn't list the anycast addresses, or
Radiator logged
Could not bind authentication socket: Cannot assign requested address
at its start (it then runs without them): set
ip_nonlocal_bind (Set up anycast on each server,
step 2) and restart Radiator. |
yubikey-anycast.sh check prints
NO REPLY within 3 s |
The SECRET in /etc/radiator/anycast.conf
isn't the one of <Client 127.0.0.1> (Radiator drops
the request without a log line). |
yubikey-anycast.sh check prints
NO REPLY: Connection refused |
Radiator isn't running, or BindAddress doesn't list
127.0.0.1. |
yubikey-anycast.sh check prints
REJECT ...: Yubikey secret not found in database |
The check's key isn't in the database (run the enroll
line of step 5 again), or Radiator can't reach the primary. |
yubikey-anycast.sh check prints
REJECT ...: Replay attack detected |
The counter file is older than the database, for example restored
from a backup. The running check skips ahead by itself after a few
checks; or make a new key with enroll. |
yubikey-anycast.sh check prints
...; TACACS+ FAIL ...: No such user |
The check's service account isn't in the database, or is disabled,
or none of its groups may log in to device group localhost:
./yubikey-db.sh -b pg access check svc.anycast-west localhost
says which. |
yubikey-anycast.sh check prints
...; TACACS+ FAIL ...: Bad Password |
The database has another password for the check's service account
than /var/lib/yubikey-anycast/tacacs. Stop the check, then
sudo ./yubikey-anycast.sh enroll-tacacs | ./yubikey-db.sh -b pg service password svc.anycast-west. |
yubikey-anycast.sh check prints
...; TACACS+ BAD REPLY ... (wrong TACACS_KEY?) |
TACACS_KEY in /etc/radiator/anycast.conf
isn't the TACACSPLUSKey of
<Client 127.0.0.1>. |
yubikey-anycast.sh check prints
...; TACACS+ NO REPLY: Connection refused |
Radiator has no <ServerTACACSPLUS>
(radiator.example.conf has none;
radiator-groups.example.conf has), or
BindAddress doesn't list 127.0.0.1. |
The check logs
cannot renew the key: the database has no function anycast_renew |
Run ./yubikey-db.sh -b pg init once, as an admin,
against the primary. |
The check logs
cannot renew the key: psql: error: ... |
It couldn't reach the primary with Radiator's login from
yubikey-cluster.conf (or yubikey.conf): check
that Radiator's password is on its first DBAuth line. It
tries again an hour later. |
The check logs
cannot renew the key: Radiator's database ... isn't PostgreSQL |
Renewing needs PostgreSQL: replace the key by hand with
enroll (see Run anycast). |
yubikey-anycast.sh check says
a renewal of the key isn't finished |
A renewal's answer from the database was lost. Run
sudo ./yubikey-anycast.sh renew (with the check stopped) or
start the check: either finishes it with the same key. |
yubikey-anycast.sh: error: net.ipv4.ip_nonlocal_bind is not 1 |
Step 2 of Set up anycast on each server. |
The check says advertised, but the router has no
route |
On the server, sudo vtysh -c 'show bgp summary': a
session that isn't Established (firewall, AS numbers,
addresses), or (Policy), which means a route map is
missing. |
With groups, Radiator logs
Access rejected for alice: No such user after her YubiKey
or password was accepted |
None of her groups may use the device's group, or her account isn't
active:
sudo ./yubikey-db.sh access check alice DEVICE_GROUP. The
device group is the Identifier of the
<Client> the device matched. |
Radiator logs
Authorization denied for alice ..., group no-access |
A TACACS+ authorization for an account that none of whose groups may use the device (any more), or that isn't active. |
Radiator logs
Authorization denied for alice, group netadmins. No matching AuthorizeGroup rule |
The group has rules on that device group, and none matches the
command: add one (rule add), or end the rules with
permit service=shell. |
Radiator logs
Inconsistent lengths in Tacacs Authentication request from ADDRESS. Bad Key? |
The device's TACACS+ key isn't the TACACSPLUSKey of its
<Client>. |
Radiator logs
Could not find a Client for ADDRESS. Falling back to default Key |
That device isn't in a <Client> and there is no
<Client DEFAULT>: add it to
/etc/radiator/clients.d and
sudo systemctl restart radiator. |
Radiator logs
No files to include with '/etc/radiator/clients.d/*.conf' |
No device files yet: only <Client 127.0.0.1> is
known. |
yubikey-db.sh: error: ... has no service-account and group tables |
Run init (or -b pg init): it adds
them. |
radiusd fails with
Can't locate Cache/FastMmap.pm |
DupCache shared needs it:
sudo apt install libcache-fastmmap-perl. |
Patroni logs
CA cert does not include key usage extension |
The CA certificate has no keyUsage, which Python 3.13
and later require. Make the CA as in Set up the three servers (step
2). |
Patroni stops with
Can not find suitable configuration of distributed configuration store
and Available implementations: consul, kubernetes |
python3-etcd is missing:
sudo apt install python3-etcd. |
Patroni logs Error: invalid version when it creates the
database |
Debian's pg_createcluster_patroni takes the PostgreSQL
version and cluster name from scope: use
scope: 18-main. |
A former primary stays running instead of
streaming, and Patroni logs
Can not fetch local timeline and lsn from replication connection
with does not match host name "localhost" |
Patroni can't tell whether the server must be rewound, because its
replication connection to itself uses TLS. Add
use_unix_socket_repl: true and the
local replication replicator line (step 4 of Set up the three servers). |
The primary's pg_stat_replication shows no standby |
The standby isn't receiving changes. Check its log, its
~postgres/.pgpass and pg_hba.conf's
replication lines. If it was offline for longer than
wal_keep_size covers, make it a standby again from
scratch. |
A disabled user can still log in, and active shows
false |
The row came from the old example commands; Radiator treats the text
false as active. Run disable. |
Log could not append ... Permission denied |
radiusd was run as root once.
sudo chown radiator:radiator /var/log/radiator/*. |
Radiator logs attempt to write a readonly database |
radiator can't write the SQLite file or its
folder. Run
sudo ./yubikey-db.sh init --owner radiator:radiator
again. |
| The first OTP works, the next ones fail | Counters aren't being saved: check the folder permissions (SQLite)
and the UpdateQuery line. |
UNIQUE constraint failed or
duplicate key |
The YubiKey is already in the database: use --update
(or --replace after reprogramming). |
import failed and was rolled back |
Nothing was loaded. Fix the cause shown above it and import again. |
nothing imported; fix the rows above |
Some CSV rows are invalid: fix them, or add
--skip-invalid. |
public ID ... has N characters; Radiator accepts only 12 |
Program the YubiKey with a 12-character public ID. |
database is locked |
Radiator was writing at that moment. The script waits 5 seconds; run it again. |
no write access to ... (run with sudo?) |
SQLite: run the script with sudo. |
no read access to the yubikeys table ... (is X a member of yubikey_admin?) |
PostgreSQL: run init-pg --admin X as
postgres, or connect as an admin. |
server certificate ... does not match host name |
PGHOST (and Radiator's host=) must be the
name in the certificate, not an IP address. |
no pg_hba.conf entry for host |
Add a hostssl line for that address and role, and
restart PostgreSQL. |
password authentication failed for user "radiator" |
A DBAuth line doesn't match the password set with
init-pg --set-password (config writes three:
one for each clause). |
setup.sh keeps
waiting for etcd on all three servers |
Run setup.sh on the other servers too. If it runs
there, the servers can't reach each other on TCP 2379 and 2380: a
firewall on the way, or a wrong address in site.toml. |
setup.sh keeps
waiting for the primary, NAME, to add this server's anycast check |
A server replaced, with a new name: run setup.sh with
the new folder on the primary (see Replace a turn-key server). |
setup.sh says
radiator X is newer than the folder's Y: left as it is |
The folder has older Radiator packages than the server: copy a
folder made with the newer ones (yubikey-turnkey -p), or
leave it so (see Upgrade a turn-key
setup) |
setup.sh keeps
waiting for the primary, NAME, to have the new passwords of replicator and rewinder
(or radiator's new password) |
After yubikey-turnkey --rotate, setup.sh
ran here before it ran on the primary: run it on the primary, and this
one goes on (see Rotate passwords
and keys) |
setup.sh: error: etcd: the cluster has the member of NAME (URL), which isn't one of the three servers in this folder, and it runs |
The server this one replaces still runs etcd: stop it first
(setup.sh --reset NAME on it). |
setup.sh keeps
waiting for east1 to create the database |
setup.sh on the first server hasn't got that far, or
stopped: see its output, fix the cause, and run it again there. |
setup.sh: error: this server's PostgreSQL isn't the primary |
The first server found another primary before it had made the
database (a failover during its first run): move the primary to it with
patronictl switchover --candidate NAME --force, and run
setup.sh again. (Once the database is made, a first server
that isn't the primary, as when rebuilt, leaves it as it is.) |
setup.sh: error: --alone: the others answer (TCP ...) |
One of the other two still runs, so it isn't lost:
--alone is only for when both are lost for good. For one
lost server, see Rebuild a turn-key
server. |
setup.sh: error: etcd: the cluster has this server's member from before, and fewer than two servers run etcd |
A rebuilt server, or one after --reset, while one of
the other two doesn't run etcd: start it there
(sudo systemctl start etcd), and run setup.sh
again. See Rebuild a turn-key
server. |
setup.sh: error: an SSH connection comes from ADDRESS, outside the admin subnets |
Loading the firewall would cut that connection: add its subnet to
[admin] subnets in site.toml and run
yubikey-turnkey again, or run
setup.sh --no-firewall. |
setup.sh: error: etcd on this server holds data that setup.sh didn't make,
or PostgreSQL's cluster 18/main has databases |
setup.sh removes only the empty ones that the packages
make. If this server may lose them, setup.sh --reset NAME
starts it over. |
yubikey-turnkey: error: ... secrets.toml is readable by others |
chmod 600 secrets.toml. |
setup.sh --status:
Patroni: the primary is east2, not east1 |
After a failover. Once East1 is a Quorum Standby again,
move the primary back:
sudo patronictl -c /etc/patroni/18-main.yml switchover --candidate east1 --force. |
setup.sh --status:
setup.sh: /root/turnkey has changed since setup.sh last finished |
A new folder was copied, but setup.sh hasn't run with
it: run it. |
setup.sh --status:
partition mode: the copy isn't updated: ... |
yubikey-partition can't read this server's PostgreSQL,
for the reason given. Expected for a while on a primary that was cut off
(Patroni stops its PostgreSQL). Otherwise see
journalctl -u yubikey-partition and
patronictl list. |
setup.sh --status:
the check's key: the renewal of DATE hasn't worked yet |
journalctl -u yubikey-anycast says why; see Run anycast. The key works until
OTPs left runs out, and it keeps trying every hour. |
yubikey-backup.sh status:
the last backup failed, ...: pg_dump: error: ... |
pg_dump couldn't read this server's PostgreSQL, for the
reason given: expected while it is down or being copied again (a
standby). sudo systemctl start yubikey-backup.service tries
again. |
yubikey-backup.sh status:
the newest backup is 30 h old (and
setup.sh --status warns) |
The timer isn't running
(systemctl status yubikey-backup.timer;
systemctl enable --now yubikey-backup.timer), or the runs
fail (journalctl -u yubikey-backup). |
yubikey-backup.sh: FILE didn't decrypt: a wrong key, or the file isn't whole or was changed |
The file was made for another certificate (an older key pair), was cut short in a copy, or was changed. Nothing was written. Try the copy from another server. |
yubikey-backup.sh: cannot read the private key backup.key: a wrong passphrase |
Type the key's passphrase (the one for backup.key, not
a server's). |
yubikey-backup.sh: error: this server's PostgreSQL is a standby: restore on the primary |
Run the restore on the primary (patronictl list, or the
server whose pg_is_in_recovery() is false). |
yubikey-backup.sh: nothing restored: ... canceling statement due to lock timeout |
Another session held a lock on a table for more than 10 s (an admin's open transaction, say). Nothing changed: end it and restore again. |
yubikey-turnkey:
backup.certificate: ... holds a private key |
Name the certificate (backup.crt) only, and keep the
key off the servers and out of out/. |
Reference
yubikey-db.sh
yubikey-db.sh [global options] <command> [command options]
| Global option | Environment | Default | Meaning |
|---|---|---|---|
-b, --backend |
DB_BACKEND |
sqlite |
sqlite or pg |
-d, --db |
DB_FILE, PGDATABASE |
/etc/radiator/db/yubicred.db, yubikey |
SQLite file, or PostgreSQL database name |
-e, --secret-encoding |
SECRET_ENCODING |
base64 |
How AES keys are stored: base64 or hex
(Radiator reads both) |
-H, --password-hash |
PASSWORD_HASH |
crypt |
How PINs are stored: crypt (SHA-512) or
clear |
-h, -V |
Help, version |
Global options go before the command. Other environment variables:
PGHOST, PGPORT, PGUSER,
PGSSLMODE, PGSSLROOTCERT,
PGSERVICE, PGCONNECT_TIMEOUT,
PGTARGETSESSIONATTRS (standard PostgreSQL settings; for a
server other than the local socket, config copies
PGHOST, PGPORT, PGSSLMODE,
PGSSLROOTCERT, PGCONNECT_TIMEOUT (default 2)
and PGTARGETSESSIONATTRS into Radiator's
DBSource; with several hosts in PGHOST,
PGTARGETSESSIONATTRS defaults to read-write),
PG_ADMIN_ROLE (default yubikey_admin),
PG_RADIATOR_ROLE (default radiator),
SQLITE3, PSQL.
| Command | Options | What it does |
|---|---|---|
init |
-o, --owner USER[:GROUP] |
SQLite: create the folder, file and table. PostgreSQL: create the table and grants in an existing database. Safe to re-run; it also brings a database made by an older version up to date. |
init-pg |
-a, --admin LOGIN (repeatable),
--set-password |
PostgreSQL, as postgres: create the roles and database,
then init. |
add |
-u USER, -P PUBLIC_ID,
-p PRIVATE_ID, -k AES_KEY,
-w PIN, -l LABEL, --no-password,
--inactive, --update or
--replace |
Add one YubiKey; asks for missing values. |
import |
--dry-run, --skip-invalid,
--update or --replace,
--from-export, FILE or - |
Load a CSV file, or restore an export. |
list |
[USER] |
Show users and YubiKeys, without secrets. |
verify |
-u USER, --no-require2factor |
Check a PIN and OTP as Radiator would; save the counters if accepted. |
enable, disable, delete |
USER [PUBLIC_ID] |
Set active to 1 or 0, or delete the user's rows (or one
YubiKey). |
label |
USER PUBLIC_ID LABEL |
Set the label of one of the user's YubiKeys: UTF-8 text, up to 255
characters, no control characters; '' removes it. |
export |
-f, --force, FILE or - |
Write every row and field to CSV (mode 0600). |
config |
Print the <AuthBy SQLYUBIKEY> clause (for
PostgreSQL, also the clauses YubiKeyConnect and
YubiKeyLocal), and the clauses ServiceAccounts
and GroupAccess for groups. |
|
service add |
NAME, -l LABEL, --inactive,
--generate |
Add a service account (svc.NAME); the password is asked
for twice, read from standard input, or generated. |
service password |
NAME, --generate |
Change its password. |
service list, enable,
disable, delete, label |
NAME, LABEL |
List the accounts, turn one on or off, delete it (and take it out of its groups), or label it. |
group add, delete, list,
label |
NAME, -l LABEL |
Add a group, delete it (with its members, access and rules), list them, or label one. |
member add, delete |
GROUP USER... |
Put accounts in a group, or take them out. |
access set |
GROUP DEVICE_GROUP PRIV_LVL,
-r RADIUS_REPLY |
Let the group log in to that device group (or *) at
that privilege level, with those RADIUS reply attributes; or change
them. |
access delete, list,
check |
GROUP DEVICE_GROUP, [GROUP],
USER DEVICE_GROUP |
Take access away (with its rules), list it, or show what an account gets on a device group (exit 1: nothing). |
rule add, delete, list |
GROUP DEVICE_GROUP 'RULE' (--seq N),
GROUP DEVICE_GROUP SEQ,
GROUP DEVICE_GROUP |
TACACS+ command rules, in order. |
It exits with 0 on success and 1 on any error.
yubikey-db-migrate.sh
yubikey-db-migrate.sh [options] SQLITE_FILE
| Option | Meaning |
|---|---|
-d, --db NAME |
PostgreSQL database (default yubikey) |
-m, --mode new|sync |
new (default): the PostgreSQL table must be empty.
sync: add missing YubiKeys and move counters forward. |
-e, --secret-encoding |
How AES keys are stored in PostgreSQL (default
base64) |
-n, --dry-run |
Do everything, then roll back |
--skip-invalid |
Copy the valid rows even if some are invalid (they are listed by user and public ID) |
yubikey-partition.sh
yubikey-partition.sh [-c FILE] COMMAND
| Command | What it does |
|---|---|
setup |
Make the copy, Radiator's clause for it
(yubikey-partition.conf) and the link
yubikey.conf -> yubikey-cluster.conf,
moving an existing yubikey.conf to
yubikey-cluster.conf. Safe to run again. |
run |
The service, as yubikey-partition.service runs it. |
status |
The mode and since when, the peers that answer, the copy, and where
yubikey.conf points. Exits 0 in normal mode, 1 in partition
mode. |
sync |
Update the copy from this server's PostgreSQL now. |
leave |
Leave partition mode now: copy the counters to the primary, point
yubikey.conf back at the cluster, restart Radiator. Changes
nothing if the primary can't be reached. |
-c FILE reads the settings from FILE instead of
/etc/radiator/partition.conf:
| Setting | Default | Meaning |
|---|---|---|
PEERS |
The other two servers, separated by spaces | |
PORTS |
5432 8008 |
A server answers when it accepts a connection on one of these |
INTERVAL |
5 |
Seconds between checks |
ENTER |
3 |
Checks in a row with no server answering before partition mode |
LEAVE |
2 |
Checks in a row with a server answering and the primary reachable before normal mode |
SYNC |
300 |
Seconds between updates of the copy (it is also updated when partition mode begins) |
It exits with 0 on success, 1 when a step fails, and 3 for a problem with the settings or the setup.
yubikey-anycast.sh
yubikey-anycast.sh [-c FILE] COMMAND
| Command | What it does |
|---|---|
enroll |
Make a new key, PIN and user (anycast- and the host
name) for the check, keep them in
/var/lib/yubikey-anycast/key, and print them as CSV for
yubikey-db.sh import --replace -. Keeps the public ID of an
earlier key. Not while the check runs. |
enroll-tacacs |
Make a new password (32 letters and digits) for the check's TACACS+
service account (svc.anycast- and the host name), keep it
in /var/lib/yubikey-anycast/tacacs, and print it for
yubikey-db.sh service add (or
service password), which reads it from standard input. Not
while the check runs. |
check |
Log in once (and over TACACS+, with TACACS_KEY), and
check that Radiator listens on the anycast addresses; exits 0 if all of
it works. Not while the check runs. |
renew |
Give the key a new private ID and AES key, in the database (through
anycast_renew, with Radiator's login) and in
/var/lib/yubikey-anycast/key, as the running check does
every RENEW_DAYS days. PostgreSQL only; not in partition
mode, and not while the check runs. |
run |
The check, as yubikey-anycast.service runs it. |
status |
Whether the addresses are on lo, since when, the last
check, the OTPs left, and the next renewal of the key. Exits 0 only
while they are on lo. |
withdraw |
Take the addresses off lo (the service does this when
it stops). |
-c FILE reads the settings from FILE instead of
/etc/radiator/anycast.conf. Settings
(NAME=value lines, never run as shell code):
| Setting | Default | Meaning |
|---|---|---|
ADDRESSES |
The anycast addresses, IPv4 and IPv6, separated by spaces | |
SECRET |
The secret of Radiator's <Client 127.0.0.1> |
|
PORT |
1812 |
Radiator's authentication port |
INTERVAL |
5 |
Seconds between checks |
TIMEOUT |
3 |
Seconds to wait for Radiator's answer (at most
INTERVAL) |
FALL |
3 |
Failed checks in a row that take the addresses off
lo |
RISE |
2 |
Good checks in a row that put them back |
TACACS_KEY |
The TACACSPLUSKey of Radiator's
<Client 127.0.0.1>. When set, each check also logs in
over TACACS+, and must pass both logins. |
|
TACACS_PORT |
49 |
Radiator's TACACS+ port |
RENEW_DAYS |
365 |
Days after which the running check renews its key (sooner if only 30
days of OTPs are left); 0: never |
It exits with 0 on success, 1 when a check fails (check)
or the addresses aren't on lo (status), and 3
for a problem with the settings, the key, the TACACS+ account's file or
the system, which the service then doesn't restart on.
yubikey-backup.sh
yubikey-backup.sh [-c FILE] COMMAND [OPTIONS]
| Command | What it does |
|---|---|
run |
Dump the database from this server's PostgreSQL (as
postgres, through the local socket), encrypted for
CERT, to DIR/yubikey-db.cms.0, moving the
others up one and keeping KEEP of them.
yubikey-backup.service runs it. |
status |
The newest backup and its age, how many are kept, the last run, and the certificate. Exits 0 when the newest is less than 26 hours old and the last run worked. |
decrypt --key KEY FILE OUT |
Decrypt FILE (a backup, or
secrets.toml.cms) to OUT (mode
0600), which it writes only when FILE is whole
and unchanged, and refuses to overwrite. |
check [--key KEY] FILE |
Decrypt FILE (or take it as a dump already), read the
dump back whole, and show when it was made and what it holds. Changes
nothing; needs pg_restore, not a database. |
restore [--key KEY] [--yes] FILE |
On the primary: show what the restore would undo, ask
(--yes: don't), and replace every table with the backup's
in one transaction, keeping the counters that are ahead and the anycast
checks' keys. |
--key KEY is the private key; openssl asks
for its passphrase, or takes it as --passin ARG says
(openssl's -passin: pass:TEXT,
env:VAR, file:PATH, fd:N).
-c FILE reads the settings from FILE instead of
/etc/radiator/backup.conf (restore works
without them, for the database yubikey;
decrypt and check don't read them):
| Setting | Default | Meaning |
|---|---|---|
CERT |
/etc/radiator/backup.crt |
The certificate the backups are encrypted for (RSA) |
KEEP |
7 |
How many backups to keep, .0 (the newest) to
.KEEP-1 (1 to 366) |
DIR |
/var/backups |
Where |
DATABASE |
yubikey |
The database |
It exits with 0 on success, 1 when a step fails (status:
no recent backup, or the last run failed), and 3 for a problem with the
settings, the options or the system.
yubikey-turnkey
yubikey-turnkey [-o DIR] [-s FILE] [-p DIR] [-n] [--renew-certs] [--rotate WHAT,...] SITE.toml
Checks SITE.toml (every error at once), makes the
secrets it lacks, and writes the folders (see Turn-key setup). It removes the files it
wrote before and no longer writes; other files in the output folder
stay.
| Option | What it does |
|---|---|
-o DIR, --out DIR |
Write to DIR instead of out/ next to
SITE.toml |
-s FILE, --secrets FILE |
The secrets: FILE instead of secrets.toml
next to SITE.toml |
-p DIR, --packages DIR |
Copy Radiator's packages (radiator_*_all.deb or
radiator-locked_*_all.deb, and
radiator-radius-utilxs_*.deb), and
radiator-yubikey_VERSION_all.deb with
install.sh, from DIR into each server's
folder |
-n, --check |
Change nothing: check the input, and say what would be written or removed |
--renew-certs |
New certificates for the three servers; the CA stays |
--rotate WHAT,... |
New passwords, secrets or keys: radiator,
patroni, admins or admin:LOGIN,
localhost, catch-all, devices or
device:ADDRESS, several with commas or
--rotate again; then setup.sh on the primary
first (see Rotate passwords and
keys). Not with --check |
SITE.toml's settings are in
site.example.toml's comments. It exits with 0 on success
(--check: everything is up to date), 1 when
--check finds something to write, and 2 for an error in the
input or the options. It needs Python 3.11 or later and
openssl.
setup.sh (yubikey-turnkey-setup.sh)
sudo ./setup.sh [--check] [--no-firewall]
sudo ./setup.sh --status
sudo ./setup.sh --reset NAME
sudo ./setup.sh --alone [--yes]
In each server's folder from yubikey-turnkey (the
package's yubikey-turnkey-setup.sh). It reads
server.env, secrets.env,
files.list and files/ next to it.
| Option | What it does |
|---|---|
| (none) | Every step, from where it stopped; each changes only what differs |
-n, --check |
Only the preflight checks: the Ubuntu version, the host name and addresses, the clock, the disk, the packages next to it, the ports, the other servers, and SSH connections from outside the admin subnets |
--no-firewall |
Leave the firewall alone this time |
--status |
Check this server, and change nothing (see Check a turn-key server); without
the folder (as
/opt/radiator-yubikey/yubikey-turnkey-setup.sh), with the
copy of server.env in
/etc/yubikey-turnkey |
--reset NAME |
Stop the services, and remove this server's etcd data, PostgreSQL
cluster, anycast key and state (NAME: its host name); the
backups in /var/backups stay. Then setup.sh on
this server alone joins the other two again; after --reset
on all three, it makes a new cluster |
--alone |
Only when the other two servers are lost for good: make this server the cluster alone, its etcd a cluster of one with its data, and its PostgreSQL the primary (see Two servers lost). It refuses while one of them answers, and asks first |
--yes |
With --alone: don't ask |
TURNKEY_WAIT sets how long it waits for the other
servers (default 1800 seconds). It exits with 0 when the server is set
up (--check: nothing to fix), 1 when something failed
(--check: something to fix), and 2 for a usage error.
--status exits with 0 (OK), 1
(WARNING), 2 (CRITICAL) or 3
(UNKNOWN: not as root, or no server.env).
install.sh
sudo ./install.sh [OPTIONS] [PACKAGE.deb]
Checks that Radiator is installed, then installs PACKAGE.deb with the
packages that it and Radiator's YubiKey support need (see The radiator-yubikey package).
PACKAGE.deb defaults to the newest
radiator-yubikey_*_all.deb next to install.sh;
without one, it installs the other packages only.
| Option | Meaning |
|---|---|
-y, --yes |
Go on without asking when Radiator is not found |
-n, --check |
Change nothing: show what is installed, what is missing and what is left to do (needs no root) |
--radiusd FILE |
Radiator's radiusd, when it is not in
/opt/radiator/radiator or on the PATH |
It exits with 0 when done (with --check, when nothing is
missing or left to do), 1 for an error (with --check, when
something is missing or left to do), 2 for a wrong option, and 3 when it
stopped because Radiator is not installed.
packaging/build-deb.sh
packaging/build-deb.sh [OUTDIR]
Builds the package, radiator-yubikey_VERSION_all.deb, in
OUTDIR (default dist/), from the files that
packaging/debian/radiator-yubikey.install names, and writes
install.sh next to it, as a release has it. VERSION is the
one in the file VERSION, and the package's date is that of
the version in CHANGELOG.md, so the same files always build
the same package, byte for byte. The package's scripts, and that
install.sh, have the version written in, so that each also
runs on its own; a script from the repository that has no
VERSION file next to it shows its version as
unknown. It needs dpkg-dev and
debhelper
(sudo apt install dpkg-dev debhelper).
tests/make-otp.sh
tests/make-otp.sh PUBLIC_ID PRIVATE_ID AES_KEY COUNTER [SESSION]
Prints the OTP a YubiKey with those values would type. Each OTP must
have a higher counter (or the same counter and a higher session) than
the last one used. It passes the AES key to openssl on its
command line: test keys only.
Database layout
The table is yubikeys, one row per YubiKey:
| Column | SQLite | PostgreSQL | Contents |
|---|---|---|---|
username |
varchar(255) |
varchar(255) |
The login name; indexed, not unique |
password |
varchar(64) |
text |
PIN: {crypt}$6$..., plain text, or empty |
active |
boolean |
smallint, 0 or 1 |
1 allows logins; Radiator treats only 0 or empty as disabled |
created |
datetime |
timestamptz |
When the row was added (UTC) |
accessed |
datetime |
timestamptz |
Last successful login; empty until then |
tokenId |
varchar(60) |
varchar(60) |
Public ID (modhex); unique |
userId |
varchar(60) |
varchar(60) |
Private ID (hex); primary key |
secret |
varchar(60) |
varchar(60) |
AES key, base64 (or hex) |
counter, low, high |
int |
integer |
The replay counters (low is the session counter;
Radiator sets high to 0) |
label |
varchar(255) |
varchar(255) |
Describes the YubiKey, for people; empty for none. Radiator doesn't read it. Added in 1.8.0. |
login_id |
varchar(64) |
PostgreSQL only: the ID of the Radiator login that last saved the counters, kept by the trigger; empty after a save by the scripts. Added in 1.9.0. |
PostgreSQL roles: yubikey_admin owns the database and
table; its members manage users. radiator can
SELECT username, password, active, tokenId, userId, secret
and the counters, and UPDATE only accessed and the
counters; it can't read the labels, nor read or write
login_id.
In both databases, the trigger yubikeys_counters_forward
refuses an UPDATE of counter or
low that doesn't move them forward, with the error
counter violation: ... already used this OTP or a later one.
To reset a YubiKey's counters on purpose, replace its row
(add --replace).
In PostgreSQL, the trigger lets one kind of such save through: the
same login saving the same counters again. Radiator does that when the
answer to its first save was lost, as when the primary stops while the
save waits for the standby (the save was made, but the connection
dropped). Radiator's UpdateQuery names the login, with the
random ID YubiKeyConnect gives it
(set_config('yubikey.login_id', ...), for that save only),
and the trigger keeps the ID in login_id. A save that names
no login, from the scripts or from a clause written by
config before 1.9.0, empties login_id. Two
logins with the same OTP have different IDs, so the second is still
refused.
In PostgreSQL, the function
anycast_renew(public ID, private ID, new private ID, new AES key)
(added in 1.10.0) is how the anycast check
renews its key with Radiator's login. It runs with the rights of its
owner, yubikey_admin, and radiator may run it.
It replaces only the row of an anycast-* user with that
public ID and private ID, by a row with the new private ID and AES key
(stored as hex or base64, as the row's was), the same username, PIN,
public ID and label, and counters at 0. It answers true
when the row has the new key, also when called again after the answer
was lost, and false when there is no such row.
The service accounts and groups (added in 1.10.0; init
adds them to an older database) are in five more tables, the same in
both databases but for the types of times and active:
| Table | Columns | Contents |
|---|---|---|
service_accounts |
username (key), password,
active, created, changed,
label |
Service accounts: svc.NAME, a {crypt}
hash, 1 or 0, when added and when the password was last set |
user_groups |
name (key), label |
Groups |
group_members |
user_group, username |
Who is in each group: YubiKey users and service accounts, by username |
group_access |
user_group, device_group,
priv_lvl, radius_reply |
The device groups each group may use (*: every one), at
privilege level 0 to 15, with RADIUS reply attributes |
group_rules |
user_group, device_group,
seq, rule |
TACACS+ command rules for an access, tried in seq
order |
The view access_replies has one row for each active
account, group it is in and device group that group may use, with the
RADIUS reply and the TACACS+ rules Radiator returns
(OSC-Authorize-Group attributes: the privilege level first,
then the rules, or permit service=shell when there are
none). GroupAccess takes the row with the highest privilege
level for the user and the device's group (or *). In
PostgreSQL, Radiator's role may read only the view and the service
accounts' username, password and
active.
Export format
export writes the header
username,public_id,private_id,aes_key,password,active,created,accessed,counter,low,high,label
and one row per YubiKey: AES keys as 32 hex characters, PINs as stored,
times in UTC, and fields with a comma, quote or line break quoted. Only
import --from-export reads it. It also reads exports made
before 1.8.0, which have no label column; with
--replace, the YubiKeys in them keep the labels they have.
export doesn't include the service accounts and groups:
back them up with the database itself (see Backups).
How Radiator 4.30 checks a login
Read from Radiator 4.30's source
(Radius/AuthYUBIKEYGENERIC.pm) and confirmed with real
logins (tests/radiator/run.sh). verify follows
the same steps.
- The login is
PIN:(optional), a 12-character public ID, and the 32-character token;PINmay also come right before the OTP without a colon. Any other public ID length is never found. - The row is found by username and public ID (
%0and%3are quoted). - The YubiKey is rejected if
activeis 0 or empty. Any other value, including the textstrueandfalse, and NULL, counts as active. - The PIN is checked if one was typed, or always with
Require2Factor. An empty stored PIN never matches.{crypt},{SSHA},$6$and other common formats, and plain text, are accepted. - A
secretof 32 hex characters is read as hex, anything else as base64. The token must decrypt with a valid CRC. - With
CheckSecretId, the token's private ID must equaluserIdexactly (lowercase hex). - The top bit of the usage counter (set when the OTP was triggered
from a keyboard key) is ignored. The OTP is a replay unless its counter
is above the stored
counter, or equal with a session counter abovelow. A NULL counter skips this check. UpdateQuerysaves the counter, the session counter (low), 0 (high) and the time. If the save fails, Radiator ignores the request (no answer) withDatabase update failed. That is what happens to the second of two logins with the same OTP at the same moment: both pass the check in step 7 before either saves, and the database refuses the second save. When a save fails because the connection dropped, Radiator connects again and repeats the same save; in PostgreSQL the trigger accepts the repeat if the first save was made.
Testing
tests/run-tests.sh runs the scripts through 49 scenarios
against throwaway SQLite and PostgreSQL databases and compares each
transcript (commands, output, exit codes, and the final table) with
tests/expected/, where the scripts' version shows as
<VERSION>. The two anycast scenarios run
yubikey-anycast.sh against a stand-in RADIUS server, which
checks each request the way Radiator does, a stand-in TACACS+ server,
and stand-ins for ip and ss;
pg_anycast renews the check's key in the test's PostgreSQL,
with Radiator's login. pg_partition runs
yubikey-partition.sh with the test's PostgreSQL as both the
local database and the primary, a stand-in for systemctl,
and a peer that is a port that listens or doesn't. groups
and pg_groups also run Radiator's GroupAccess
query from config on the database (on PostgreSQL, as
Radiator's role). install runs install.sh with
stand-ins for apt-get, dpkg-query and
Radiator, installed in each of the ways it recognizes.
turnkey runs yubikey-turnkey on
site.example.toml with fixed secrets
(tests/turnkey/example-secrets.toml) and shows everything
it writes; turnkey_errors its checks of the input,
turnkey_secrets the secrets it makes, and changed by hand,
and turnkey_rotate what each --rotate makes
anew (and nothing else). backup runs
yubikey-backup.sh's settings, status and
decrypt (of whole, changed and cut files), and
pg_backup its backups, checks and restores in the test's
PostgreSQL, with the test key pair in tests/turnkey/
(passphrase test-only). It needs bash,
sqlite3, perl, and, for the
verify and anycast scenarios,
openssl and libcrypt-rijndael-perl
(dpkg-deb for install, and Python 3.11 and
openssl for the turnkey ones); it runs as a
normal user.
tests/run-tests.sh # every scenario
tests/run-tests.sh add verify # some scenarios
tests/run-tests.sh --update # accept a deliberate change; then review it with git diff
The pg_* and migrate_* scenarios need a
PostgreSQL 18 server you can reach as a role with CREATEDB
and CREATEROLE
(sudo -u postgres createuser --createdb --createrole "$USER");
without one they are skipped. They create and drop databases and roles
named yk_test_<pid>_*.
tests/radiator/run.sh DIR tests against Radiator itself,
in containers built from the Radiator packages in DIR (see
The automated test).
Which tests a change needs
tests/run-tests.sh (about 3 minutes) and
shellcheck -x on the scripts run for every change. The
seven labs of tests/radiator/run.sh take about 65 minutes
together (turnkey.sh half of it), so a change runs the labs
that use what it changed, and a release, or a new Radiator version, runs
all seven:
| What changed | Labs |
|---|---|
yubikey-db.sh's commands,
yubikey-db-migrate.sh,
yubikey-db-user.example.sh |
e2e; turnkey too for the commands
setup.sh runs (init-pg, import,
delete, and those for groups, members, rules, access and
service accounts) |
yubikey-db.sh config (Radiator's clauses and hooks),
radiator.example.conf, dictionary.yubikey |
e2e, groups, ha,
geo, turnkey |
The groups: radiator-groups.example.conf, and their
queries in config |
groups, turnkey |
| The database's tables | all seven |
yubikey-anycast.sh |
e2e, groups, geo,
turnkey |
yubikey-partition.sh,
yubikey-backup.sh |
geo, turnkey |
install.sh |
package, turnkey,
upgrade |
packaging/, the systemd units |
package, geo, turnkey,
upgrade |
yubikey-turnkey,
yubikey-turnkey-setup.sh |
turnkey; upgrade too when it changes what
setup.sh installs, or how a server is upgraded |
| A lab's script | that lab; turnkey-lib.sh: turnkey and
upgrade; run.sh, Dockerfile: all
seven |
Only this guide, README.md or
CHANGELOG.md |
none (rebuild yubikey-db-guide.html) |
For example, a change to how setup.sh installs
packages:
tests/run-tests.sh && shellcheck -x *.sh tests/*.sh tests/radiator/*.sh
tests/radiator/run.sh ~/radiator-packages turnkey upgrade # about 40 minutes
References
- Radiator
reference:
<AuthBy SQLYUBIKEY> - Radiator
reference:
AuthSelectfor SQLYUBIKEY - The Radiator distribution's
goodies/yubikey.cfgandgoodies/yubikey.txt - YubiKey
Manager (
ykman) documentation - PostgreSQL 18: SSL support and the pg_hba.conf file
- PostgreSQL
18: log-shipping standby servers,
pg_basebackupand libpq connection strings with several hosts