Index
By using the “Human Made” badge, I affirm that I am not using data-generated images (“genAI”) or data-generated text (“genAI”) in any asset of my work. My workflow consists of only using my skill crafted from experience and practice.
This site serves as a convenient, central location for standalone code snippets, commands, and tutorials. Some of these used to be on my blog, but I figure that they are best placed here so that potential readers don’t have to look through my blog archives, as my blog mostly contains infodumps about tech and other topics.
Feel free to use any of the content here without obligation. All articles are CC0 and no credits or attributions are required.
Get the latest release from a GitHub repo
The following is a collection of ways to get the latest release of a program from its GitHub repository, using the command line. The example repositories used below can be replaced with any repository hosted on GitHub.
sed
This gets the URL of the latest release using sed. It then filters out files that contain “.rpm” and “.deb”.
curl -s https://api.github.com/repos/matheus-git/systemd-manager-tui/releases/latest \
| sed -n 's/.*"browser_download_url": //p' \
| grep -v -E ".rpm|.deb" \
| tr -d '"'
Output:
https://github.com/matheus-git/systemd-manager-tui/releases/download/v1.2.4/systemd-manager-tui
To get the SHA256 digest corresponding to the file, use jq in combination with awk.
curl -s https://api.github.com/repos/matheus-git/systemd-manager-tui/releases/latest \
| jq -c '.assets.[] | select(.browser_download_url | contains(".deb") or contains(".rpm") | not) | .digest' \
| tr -d '"' \
| awk -F: '{print $2}'
Output:
63a977112f97462d55a4e41fc0280d32b3f801c3b9f0fad329cd79843c7ae1ec
jq
If you know that the file you want contains “bw-linux-arm64” in the filename, you can use jq to select it.
curl -s https://api.github.com/repos/bitwarden/clients/releases/latest \
| jq -r '.assets[] | select(.name | contains("bw-linux-arm64")) | .browser_download_url'
Output:
https://github.com/bitwarden/clients/releases/download/cli-v2026.3.0/bw-linux-arm64-2026.3.0.zip
You can then pipe this to curl via the xargs command to download the file to the current working directory.
curl -s https://api.github.com/repos/bitwarden/clients/releases/latest \
| jq -r '.assets[] | select(.name | contains("bw-linux-arm64")) | .browser_download_url' \
| xargs -I {} curl -SL -O {}
Nushell
http get https://api.github.com/repos/bitwarden/clients/releases/latest
| get assets.browser_download_url
| find "bw-linux-arm64"
| get 0
Output:
https://github.com/bitwarden/clients/releases/download/cli-v2026.3.0/bw-linux-arm64-2026.3.0.zip
More examples
Mass delete repos from Forgejo, Gitea, or Codeberg
CAUTION: There is no confirmation when deleting repos.
First, you need an access token, which you can generate under User Settings -> Applications. Make sure it has at least read and write access for repository and user.
Delete select repos
#!/usr/bin/env bash
set -euo pipefail
# Change the values below to your own. The values below are just examples.
INSTANCE_URL=https://codeberg.org
INSTANCE_USER=hyperreal
INSTANCE_TOKEN=cd188e90f2f03eed57b1971fb16329b1b919a2bb
# Add the repository names to an array. E.g., if your repo is located at
# https://codeberg.org/hyperreal/dotfiles, the name you would add would be "dotfiles".
repos=("repo0" "repo1" "dotfiles" "repo2")
# Iterate through the array and delete each item using curl.
for repo in "${repos[@]}"; do
echo "Deleting repository: ${repo}"
curl -s \
-u "${INSTANCE_USER}:${INSTANCE_TOKEN}" \
-X DELETE \
"${INSTANCE_URL}/api/v1/repos/${INSTANCE_USER}/${repo}"
done
echo "All your repos are belong to internet abyss! Arrivederci, baby!"
Delete all the repos
If for whatever reason you want to delete all the repos on your account, change the line with the repos=("repo0" "repo1" "dotfiles" "repo2") array to the following:
readarray -t repos < <(curl -s -u "${INSTANCE_USER}:${INSTANCE_TOKEN} "${INSTANCE_URL}/api/v1/users/${INSTANCE_USER}/repos?limit=100" | jq -r '.[].name')
Bye-bye, repos. 👋
Profile Sync Daemon config for Waterfox
It’s basically the same as Firefox except for the path to the profiles.
Create the file /usr/share/psd/browsers/waterfox and add the following contents.
if [[ -d "$XDG_CONFIG_HOME/waterfox" ]]; then
index=0
PSNAME="$browser"
while read -r profileItem; do
if [[ $(echo "$profileItem" | cut -c1) = "/" ]]; then
# path is not relative
DIRArr[$index]="$profileItem"
else
# we need to append the default path to give a
# fully qualified path
DIRArr[$index]="$XDG_CONFIG_HOME/waterfox/$profileItem"
fi
(( index=index+1 ))
done < <(grep '^[Pp]ath=' "$XDG_CONFIG_HOME/waterfox/profiles.ini" | sed 's/[Pp]ath=//')
fi
check_suffix=1
if [[ -d "$HOME"/.waterfox ]]; then
index=0
PSNAME="$browser"
while read -r profileItem; do
if [[ $(echo "$profileItem" | cut -c1) = "/" ]]; then
# path is not relative
DIRArr[$index]="$profileItem"
else
# we need to append the default path to give a
# fully qualified path
DIRArr[$index]="$HOME/.waterfox/$profileItem"
fi
(( index=index+1 ))
done < <(grep '^[Pp]ath=' "$HOME"/.waterfox/profiles.ini | sed 's/[Pp]ath=//')
fi
check_suffix=1
Add Waterfox to ~/.config/psd/psd.conf.
BROWSERS=(waterfox)
Restart the psd.service.
systemctl --user restart psd.service
Waterfox swim real fast now 😀. In theory, at least.
Profile Sync Daemon for Zen Browser Flatpak
profile-sync-daemon is a li’l daemon for managing browser profiles in a temporary RAM filesystem and periodically syncing them back to the physical disk. This improves the responsiveness of the browser and reduces wear on physical drives.
Zen Browser is not officially supported by the profile-sync-daemon, but there is community support for the non-Flatpak version of Zen Browser. This can be activated by copying /usr/share/psd/contrib/zen to /usr/share/psd/browsers/. However, this won’t work for the Flatpak version of Zen Browser unless you do the following:
- Modify the
/usr/share/psd/contrib/zenfile to use the path to the Flatpak’s browser profile. - Give the Flatpak permission to access the temporary RAM filesystem.
To give the Zen Browser Flatpak permission to access the temporary RAM filesystem where PSD stores the profiles, run the following command:
flatpak override --user app.zen_browser.zen --filesystem=/run/user/$UID/psd
Now put the following contents into a file at /usr/share/psd/browsers/zen-flatpak:
if [[ -d "$HOME"/.var/app/app.zen_browser.zen/cache/zen ]]; then
index=0
PSNAME="$browser"
while read -r profileItem; do
if [[ $(echo "$profileItem" | cut -c1) = "/" ]]; then
# path is not relative
DIRArr[$index]="$profileItem"
else
# we need to append the default path to give a
# fully qualified path
DIRArr[$index]="$HOME/.var/app/app.zen_browser.zen/cache/zen/$profileItem"
fi
(( index=index+1 ))
done < <(grep '^[Pp]'ath= "$HOME"/.var/app/app.zen_browser.zen/.zen/profiles.ini | sed 's/^[Pp]ath=//')
fi
check_suffix=1
Edit ~/.config/psd/psd.conf:
BROWSERS=(zen-flatpak)
Restart the psd.service:
systemctl --user restart psd.service
That’s it.
Home networking and preventing DNS leaks
I think I’ve figured out how to prevent my Fedora Linux desktop from leaking DNS to Xfinity. My Linux desktop is part of a Tailscale network that uses Mullvad’s ad-blocking and malware blocking public DNS server, but I still have DNS leaks because my main network interface is using the Xfinity DNS servers from its DHCP connection via the Xfinity modem. Below are the steps I took to prevent DNS leaks to Xfinity.
systemd-networkd
NetworkManager is the default on Fedora 40. I disabled NetworkManager and enabled systemd-networkd with the following configuration:
[Match]
Name=eno1
[Network]
DHCP=yes
DNS=100.100.100.100
DNSSEC=allow-downgrade
[DHCPv4]
UseDNS=no
In the [DHCPv4] section, UseDNS=no ensures that you’re not using the DNS servers provided by the DHCP connection. In my case, my DHCP connection via my Xfinity modem was setting the DNS to the Xfinity DNS servers. So that is no longer the case now. For good measure, I added my tailnet’s DNS as a static DNS server in the [Network] section.
resolvectl status now shows that my primary network interface eno1 uses only the DNS from my Tailscale network, which is 100.100.100.100.

Disable IPv6
Another possible source of DNS leaks is IPv6. On Fedora 40, the way to disable IPv6 is by adding a kernel argument to the GRUB bootloader configuration. This can be done with the following command:
sudo grubby --args`ipv6.disable`1 --update-kernel=ALL
Reboot the system for the change to take effect.
Ensure Firefox or LibreWolf are not using DNS over HTTPS
In most cases Firefox/LibreWolf are configured to use the system DNS resolver by default, but if you have it configured to use one of the “protection” settings for DNS over HTTPS, this could be a source of DNS leaks.
Now you can check for DNS leaks with Mullvad’s connection checker.
Network-wide bullshit-blocking setup with Blocky and Tailscale
I will use an Orange Pi 5 Plus, but any device, including single board computers, should work, as long as they can run the latest stable Debian or Armbian release.
Orange Pi 5 Plus
- Unbound for recursive DNS resolver on 127.0.0.1:5335
- Blocky for DNS proxy, ad-blocking, and malware-blocking on 0.0.0.0:53. Uses Unbound on 127.0.0.1:5335 as upstream resolver.
- Tailscale with
–accept-dns=false unbound-resolveconf.serviceshould be disabled, and/etc/resolv.confshould not be managed by any other service.
I just put the following contents into /etc/resolv.conf for the Orange Pi 5 Plus’s local DNS resolution:
nameserver 9.9.9.9
nameserver 149.112.112.112
I have Blocky configured to use the strict strategy for the upstreams setting, so after a timeout of the topmost upstream server it will fallback to the next one, which is Quad9.
An idea I have is to setup a cheap VPS on Vultr or something and run a public DNS resolver on it, but Quad9 is fine for now.
I have the Orange Pi 5 Plus’s Tailnet IP address configured to be my Tailnet’s global nameserver. This can be done through the Tailscale admin console under the DNS tab. So every device on my Tailnet that uses MagicDNS will be using Blocky and Unbound.
Blocky configuration
upstreams:
strategy: strict
groups:
default:
- 127.0.0.1:5335
- 9.9.9.9
- 149.112.112.112
blocking:
denylists:
ads:
- <https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts>
- <https://adaway.org/hosts.txt>
- <https://v.firebog.net/hosts/AdguardDNS.txt>
suspicious:
- <https://v.firebog.net/hosts/static/w3kbl.txt>
tracking:
- <https://gitlab.com/quidsup/notrack-blocklists/raw/master/notrack-blocklist.txt>
- <https://v.firebog.net/hosts/Easyprivacy.txt>
- <https://v.firebog.net/hosts/Prigent-Ads.txt>
malicious:
- <http://phishing.mailscanner.info/phishing.bad.sites.conf>
- <https://v.firebog.net/hosts/Prigent-Crypto.txt>
- <https://raw.githubusercontent.com/StevenBlack/hosts/master/alternates/fakenews/hosts>
clientGroupsBlock:
default:
- ads
- suspicious
- tracking
- malicious
ports:
dns: 53
http: 4000
prometheus:
enable: yes
caching:
minTime: 60s
maxItemsCount: 10000
prefetching: yes
prefetchMaxItemsCount: 2000
queryLog:
type: csv-client
target: /home/jas/dns-query-logs
logRetentionDays: 5
clientLookup:
upstream: 10.0.0.1
singleNameOrder:
- 1
If you’re using a firewall, make sure ingress traffic to UDP port 53 is allowed on the Tailscale interface.
Tailscale DNS
Go to the Tailscale admin web UI. In the DNS tab, under Global nameservers, add the Orange Pi 5+’s tailnet IP address. Remove any other nameservers from this section. Make sure Override DNS servers is switched on. All the devices on your tailnet that have MagicDNS enabled will now be using Blocky and Unbound to block and resolve DNS queries.
Not using Tailscale?
This setup can be achieved without using Tailscale by setting the local nameserver on each of your devices to the Orange Pi 5+’s LAN IP address. The downside to this is that it is only available to devices connected to your LAN.
Setup a thick VNET jail on FreeBSD
Setup the VNET bridge
Create the bridge.
ifconfig bridge create
Attach the bridge to the main network interface. igc0 in this case. For some reason, the resulting bridge device is named igb0bridge, rather than bridge0.
ifconfig igb0bridge addm igc0
To make this persistent across reboots, add the following to /etc/rc.conf.
defaultrouter="10.0.0.1"
cloned_interfaces="igb0bridge"
ifconfig_igc0bridge="inet 10.0.0.8/24 addm igc0 up"
Create the classic (thick) jail
Create the ZFS datasets for the jails. We’ll use basejail as a template for subsequent jails.
zfs create -o mountpoint=/jails naspool/jails
zfs create naspool/jails/basejail
Use the bsdinstall utility to bootstrap the base system to the basejail.
export DISTRIBUTIONS="base.txz"
export BSDINSTALL_DISTSITE=https://download.freebsd.org/ftp/releases/amd64/14.2-RELEASE/
bsdinstall jail /jails/basejail
Run freebsd-update to update the base jail.
freebsd-update -b /jails/basejail fetch install
freebsd-update -b /jails/basejail IDS
We now snapshot the basejail and create a clone of this snapshot for the torrenting jail that we will use for Anna’s Archive.
zfs snapshot naspool/jails/basejail@`freebsd-version`
zfs clone naspool/jails/basejail@`freebsd-version` naspool/jails/torrenting
We now use the following configuration for /etc/jail.conf.
torrenting {
exec.consolelog = "/var/log/jail_console_${name}.log";
allow.raw_sockets;
exec.clean;
mount.devfs;
devfs_ruleset = 11;
path = "/jails/${name}";
host.hostname = "${name}";
vnet;
vnet.interface = "${epair}b";
$id = "127";
$ip = "10.0.0.${id}/24";
$gateway = "10.0.0.1";
$bridge = "igb0bridge";
$epair = "epair${id}";
exec.prestart = "/sbin/ifconfig ${epair} create up";
exec.prestart += "/sbin/ifconfig ${epair}a up descr jail:${name}";
exec.prestart += "/sbin/ifconfig ${bridge} addm ${epair}a up";
exec.start += "/sbin/ifconfig ${epair}b ${ip} up";
exec.start += "/sbin/route add default ${gateway}";
exec.start += "/bin/sh /etc/rc";
exec.stop = "/bin/sh /etc/rc.shutdown";
exec.poststop = "/sbin/ifconfig ${bridge} deletem ${epair}a";
exec.poststop += "/sbin/ifconfig ${epair}a destroy";
}
Now we create the devfs ruleset to enable access to devices under /dev inside the jail. Add the following to /etc/devfs.rules.
[devfsrules_jail_vnet=11]
add include $devfsrules_hide_all
add include $devfsrules_unhide_basic
add include $devfsrules_unhide_login
add include $devfsrules_jail
add path 'tun*' unhide
add path 'bpf*' unhide
Enable the jail utility in /etc/rc.conf.
sysrc jail_enable="YES"
sysrc jail_parallel_start="YES"
Start the jail service for torrenting jail.
service jail start torrenting
Setting up Wireguard inside the jail
Since we have the /dev/tun* devfs rule, we now need to install Wireguard inside the jail.
jexec -u root torrenting
pkg install wireguard-tools wireguard-go
Download a Wireguard configuration for ProtonVPN, and save it to /usr/local/etc/wireguard/wg0.conf.
Enable Wireguard to run when the jail boots up.
sysrc wireguard_enable="YES"
sysrc wireguard_interfaces="wg0"
Start the Wireguard daemon and make sure you are connected to it properly.
service wireguard start
curl ipinfo.io
The curl command should display the IP address of the Wireguard server defined in /usr/local/etc/wireguard/wg0.conf.
Setting up qBittorrent inside the jail
Install the qbittorrent-nox package.
pkg install -y qbittorrent-nox
Before running the daemon from /usr/local/etc/rc.d/qbittorrent, we must run the qbittorrent command from the shell so that we can see the default password generated for the web UI. For some reason it is not shown in any logs, and the qbittorrent nox manpage wrongly says the password is “adminadmin”.
pkg install -y sudo
sudo -u qbittorrent qbittorrent-nox --profile=/var/db/qbittorrent/conf --save-path=/var/db/qbittorrent/Downloads --confirm-legal-notice
Copy the password displayed after running the command. Login to the qBittorrent web UI at http://10.0.0.127:8080 with login admin and the password you copied. In the web UI, open the options menu and go over to the Web UI tab. Change the login password to your own. Save the options to close the menu.
Now press CTRL-c to stop the qbittorrent-nox process. Make the following changes to the torrenting jail’s /etc/rc.conf.
sysrc qbittorrent_enable="YES"
sysrc qbittorrent_flags="--confirm-legal-notice"
Enable the qBittorrent daemon.
service qbittorrent start
Go back to the web UI at http://10.0.0.127:8080. Go to the options menu and go over to the Advanced tab, which is the very last tab. Change the network interface to wg0.
Finding the forwarded port that the ProtonVPN server is using
Install the libnatpmp package.
Make sure that port forwarding is allowed on the server you’re connected to, which it should be if you enabled it while creating the Wireguard configuration on the ProtonVPN website. Run the natpmpc command against the ProtonVPN Wireguard gateway.
natpmpc -g 10.2.0.1
If the output looks like the following, you’re good.
initnatpmp() returned 0 (SUCCESS)
using gateway : 10.2.0.1
sendpublicaddressrequest returned 2 (SUCCESS)
readnatpmpresponseorretry returned 0 (OK)
Public IP address : 62.112.9.165
epoch = 58081
closenatpmp() returned 0 (SUCCESS)
Now create the UDP and TCP port mappings, then loop natpmpc so that it doesn’t expire.
while true ; do date ; natpmpc -a 1 0 udp 60 -g 10.2.0.1 && natpmpc -a 1 0 tcp 60 -g 10.2.0.1 || { echo -e "ERROR with natpmpc command \a" ; break ; } ; sleep 45 ; done
The port allocated for this server is shown on the line that says “Mapped public port XXXXX protocol UDP to local port 0 lifetime 60”. Port forwarding is now activated. Copy this port number and, in the qBittorrent web UI options menu, go to the Connections tab and enter it into the “Port used for incoming connections” box. Make sure to uncheck the “Use UPnP/NAT-PMP port forwarding from my router” box.
If the loop terminates, you’ll need to re-run this loop script each time you start a new port forwarding session or the port will only stay open for 60 seconds.
P2P NAT port forwarding script with supervisord
Install supervisord.
sudo pkg install -y py311-supervisor
Enable the supervisord service.
sudo sysrc supervisord_enable="YES"
Edit /usr/local/etc/supervisord.conf, and add the following to the bottom of the file.
[program:natpmpcd]
command=/usr/local/bin/natpmpcd
autostart=true
Add the following contents to a file at /usr/local/bin/natpmpcd.
#!/bin/sh
port=$(/usr/local/bin/natpmpc -a 1 0 udp 60 -g 10.2.0.1 | grep "Mapped public port" | awk '{print $4}')
echo $port | tee /usr/local/etc/natvpn_port.txt
while true; do
date
if ! /usr/local/bin/natpmpc -a 1 0 udp 60 -g 10.2.0.1 && /usr/local/bin/natpmpc -a 1 0 tcp 60 -g 10.2.0.1; then
echo "error Failure natpmpc $(date)"
break
fi
sleep 45
done
Ensure the script is executable with chmod +x /usr/local/bin/natpmpcd.
supervisord will start the above shell script automatically. Ensure supervisord service is started.
sudo service supervisord start
The script will print out the forwarded port number at /usr/local/etc/natvpn_port.txt.
cat /usr/local/etc/natvpn_port.txt
48565
Install Chimera Linux
Requirements
- UEFI
- LVM on LUKS with unencrypted
/boot
Disk partitioning
Use cfdisk to create the following partition layout.
| Partition Type | Size |
|---|---|
| EFI | +600M |
| boot | +900M |
| Linux | Remaining space |
Format the unencrypted partitions:
mkfs.vfat /dev/nvme0n1p1
mkfs.ext4 /dev/nvme0n1p2
Create LUKS on the remaining partition:
cryptsetup luksFormat /dev/nvme0n1p3
cryptsetup luksOpen /dev/nvme0n1p3 crypt
Create a LVM2 volume group for /dev/nvme0n1p3, which is located at /dev/mapper/crypt:
vgcreate chimera /dev/mapper/crypt
Create logical volumes in the volume group:
lvcreate --name swap -L 8G chimera
lvcreate --name root -l 100%FREE chimera
Create the filesystems for the logical volumes:
mkfs.ext4 /dev/chimera/root
mkswap /dev/chimera/swap
Create mount points for the chroot and mount the filesystems:
mkdir /media/root
mount /dev/chimera/root /media/root
mkdir /media/root/boot
mount /dev/nvme0n1p2 /media/root/boot
mkdir /media/root/boot/efi
mount /dev/nvme0n1p1 /media/root/boot/efi
Bootstrap
Chimera-bootstrap and chroot
chimera-bootstrap /media/root
chimera-chroot /media/root
Update the system:
apk update
apk upgrade --available
Install kernel, cryptsetup, and lvm2 packages:
apk add linux-stable cryptsetup-scripts lvm2
Fstab
genfstab / >> /etc/fstab
Crypttab
echo "crypt /dev/disk/by-uuid/$(blkid -s UUID -o value /dev/nvme0n1p3) none luks" > /etc/crypttab
Initramfs refresh
update-initramfs -c -k all
GRUB
apk add grub-x86_64-efi
grub-install --efi-directory=/boot/efi --target=x86_64-efi
Post-installation
passwd root
apk add zsh bash
useradd -c "Jeffrey Serio" -m -s /usr/bin/zsh -U jas
passwd jas
Add the following lines to /etc/doas.conf:
# Give jas access
permit nopass jas
Set hostname, timezone, and hwclock:
echo "falinesti" > /etc/hostname
ln -sf /usr/share/zoneinfo/America/Chicago /etc/localtime
echo localtime > /etc/hwclock
Xorg and Xfce4
apk add xserver-xorg xfce4
Reboot the machine.
Post-reboot
Login as jas. Run startxfce4. Connect to the internet via NetworkManager.
Ensure wireplumber and pipewire-pulse are enabled:
dinitctl enable wireplumber
dinitctl start wireplumber
dinitctl enable pipewire-pulse
dinitctl start pipewire-pulse
Install CPU microcode:
doas apk add ucode-intel
doas update-initramfs -c -k all
Install other packages
doas apk add chrony
doas dinitctl enable chrony
doas apk add ...
Debian with LUKS2 Btrfs and GRUB via Debootstrap
Source: <https://gist.github.com/meeas/b574e4bede396783b1898c90afa20a30>
- Use a Debian Live ISO
- Single LUKS2 encrypted partition
- Single Btrfs filesystem with
@,@home,@swap, and other subvolumes. - Encrypted swapfile in Btrfs subvolume
- Optional removal of crypto keys from RAM during laptop suspend
- Optional configurations for laptops
Pre-installation setup
Boot into the live ISO, open a terminal, and become root. Install the needed packages.
sudo -i
apt update
apt install -y debootstrap cryptsetup arch-install-scripts
Create partitions.
cfdisk /dev/nvme0n1
- GPT partition table
- 512M
/dev/nvme0n1p1EFI System Partition (EF00) - 100%+
/dev/nvme0n1p2Linux filesystem
mkfs.fat -F 32 -n EFI /dev/nvme0n1p1
cryptsetup -y -v --type luks2 luksFormat --label Debian /dev/nvme0n1p2
cryptsetup luksOpen /dev/nvme0n1p2 cryptroot
mkfs.btrfs /dev/mapper/cryptroot
Make Btrfs subvolume.
mount /dev/mapper/cryptroot /mnt
btrfs subvolume create /mnt/@
btrfs subvolume create /mnt/@home
btrfs subvolume create /mnt/@swap
umount -lR /mnt
Re-mount subvolumes as partitions.
mount -t btrfs -o defaults,subvol=@,compress=zstd:1 /dev/mapper/cryptroot /mnt
mkdir -p /mnt/{boot,home}
mkdir /mnt/boot/efi
mount /dev/nvme0n1p1 /mnt/boot/efi
mount -t btrfs -o defaults,subvol=@home,compress=zstd:1 /dev/mapper/cryptroot /mnt/home
Setup swapfile.
mkdir -p /mnt/swap
mount -t btrfs -o subvol=@swap /dev/mapper/cryptroot /mnt/swap
touch /mnt/swap/swapfile
chmod 600 /mnt/swap/swapfile
chattr +C /mnt/swap/swapfile
btrfs property set ./swapfile compression none
dd if=/dev/zero of=/mnt/swap/swapfile bs=1M count=16384
mkswap /mnt/swap/swapfile
swapon /mnt/swap/swapfile
Base installation
Create a nested subvolume for /var/log under the @ subvolume. This will be automounted with @ so there is no need to add it to /etc/fstab. Nested subvolumes are not included in snapshots of the parent subvolume. Creating a nested subvolume for /var/log will ensure the log files remain untouched when we restore the rootfs from a snapshot.
mkdir -p /mnt/var
btrfs subvolume create /mnt/var/log
debootstrap --arch amd64 <suite> /mnt
Bind the pseudo-filesystems for chroot.
mount --rbind /dev /mnt/dev
mount --rbind /sys /mnt/sys
mount -t proc proc /mnt/proc
Generate fstab.
genfstab -U /mnt >> /mnt/etc/fstab
Chroot into the new system.
cp -v /etc/resolv.conf /mnt/etc/
chroot /mnt
Configure the new installation
Set the timezone, locale, keyboard configuration, and console.
apt install -y locales
dpkg-reconfigure tzdata locales keyboard-configuration console-setup
Set the hostname.
echo 'hostname' > /etc/hostname
echo '127.0.1.1 hostname.localdomain hostname' >> /etc/hosts
Configure APT sources on /etc/apt/sources.list.
deb https://deb.debian.org/debian <suite> main contrib non-free non-free-firmware
deb https://deb.debian.org/debian <suite>-updates main contrib non-free non-free-firmware
deb https://deb.debian.org/debian <suite>-backports main contrib non-free non-free-firmware
deb https://deb.debian.org/debian-security <suite>-security main contrib non-free non-free-firmware
Install essential packages.
apt update -t <suite>-backports
apt dist-upgrade -t <suite>-backports
apt install -t <suite>-backports -y neovim linux-image-amd64 linux-headers-amd64 firmware-linux firmware-linux-nonfree sudo command-not-found systemd-timesyncd systemd-resolved cryptsetup cryptsetup-initramfs efibootmgr btrfs-progs grub-efi
Install desktop environment.
apt install task-gnome-desktop task-desktop task-ssh-server
If installing on a laptop:
apt install -y task-laptop powertop
Create users and groups.
passwd root
adduser jas
echo "jas ALL=(ALL) NOPASSWD: ALL" | tee -a /etc/sudoers.d/jas
chmod 440 /etc/sudoers.d/jas
usermod -aG systemd-journal jas
Setting up the bootloader
Optional package for extra protection of suspended laptops.
apt install cryptsetup-suspend
Setup encryption parameters.
blkid -s UUID -o value /dev/nvme0n1p2
Edit /etc/crypttab.
cryptroot UUID=<uuid> none luks
Setup bootloader.
grub-install --target=x86_64-efi --efi-directory=/boot/efi --recheck --bootloader-id="Debian"
Edit /etc/default/grub.
GRUB_CMDLINE_LINUX_DEFAULT=""
GRUB_CMDLINE_LINUX=""
GRUB_ENABLE_CRYPTODISK=y
GRUB_TERMINAL=console
Update grub.
update-grub
Exit chroot and reboot.
exit
umount -lR /mnt
reboot
Emergency recovery from live ISO
sudo -i
cryptsetup luksOpen /dev/nvme0n1p2 cryptroot
mount -t btrfs -o defaults,subvol=@,compress=zstd:1 /dev/mapper/cryptroot /mnt
mount /dev/nvme0n1p1 /mnt/boot/efi
mount -t btrfs -o defaults,subvol=@home,compress=zstd:1 /dev/mapper/cryptroot /mnt/home
mount -t btrfs -o subvol=@swap /dev/mapper/cryptroot /mnt/swap
swapon /mnt/swap/swapfile
mount --rbind /dev /mnt/dev
mount --rbind /sys /mnt/sys
mount -t proc proc /mnt/proc
chroot /mnt
Create an RPM repository
Install dependencies
sudo dnf install gnupg createrepo dnf-utils rpm-sign wget
Setup GnuPG
echo "%echo Generating a PGP key
Key-Type: RSA
Key-Length: 4096
Name-Real: Jeffrey Serio
Name-Email: hyperreal@moonshadow.dev
Expire-Date: 0
%no-ask-passphrase
%no-protection
%commit" > ~/hyperreal-pgp-key.batch
Now generate the key with the following command:
gpg --no-tty --batch --gen-key ~/hyperreal-pgp-key.batch
Export the public key.
gpg --armor --export "Jeffrey Serio" > ~/hyperreal-pgp-key.pub
Export the private key to back it up somewhere safe.
gpg --armor --export-secret-keys "Jeffrey Serio" > ~/hyperreal-pgp-key.sec
After backup up the private key, shred it from the working directory.
shred -xu ~/hyperreal-pgp-key.sec
Setup RPM signing
Replace E1933532750E9EEF with your key’s ID.
echo "%_signature gpg
%_gpg_name E1933532750E9EEF" > ~/.rpmmacros
Create a directory to serve the repository.
mkdir -p ~/rpm-repo/packages
Move RPM packages into the repo directory. Then sign them with the following command:
rpm --addsign ~/rpm-repo/packages/*.rpm
Create repo index
Once all the packages are signed, create the repository with the following command:
createrepo ~/rpm-repo/packages/
The above command will create a directory in the repo named repodata containing a file named repomd.xml.
Note that the
createrepocommand must be run against each directory in the repo that contains .rpm files.
Now sign the repo metadata with the following command:
gpg --detach-sign --armor ~/rpm-repo/packages/repodata/repomd.xml
Create a .repo file
~/rpm-repo/hyperreal-kernel-bazzite.repo
[hyperreal-kernel-bazzite]
name=hyperreal kernel bazzite $releasever
baseurl=https://rpm.hyperreal.coffee/kernel-bazzite/fedora-$releasever/$basearch
enabled=1
gpgcheck=1
gpgkey=https://rpm.hyperreal.coffee/hyperreal-pgp-key.pub
The RPM repository should now be ready to be served on a web server with ~/rpm-repo as the web root.
Example Caddy configuration
rpm.hyperreal.coffee {
root * /home/jas/rpm-repos/
file_server browse
}
Install Cgit with Caddy
Dependencies
- xcaddy package from releases page.
Install caddy-cgi:
xcaddy build --with github.com/aksdb/caddy-cgi/v2
Install remaining dependencies:
sudo apt install gitolite3 cgit python-is-python3 python3-pygments python3-markdown docutils-common groff
Configuration
Make a git user.
sudo adduser --system --shell /bin/bash --group --disabled-password --home /home/git git
Configure gitolite for the git user in ~/.gitolite.rc.
UMASK => 0027,
GIT_CONFIG_KEYS => 'gitweb.description gitweb.owner gitweb.homepage gitweb.category',
Add caddy user to the git group.
sudo usermod -aG git caddy
Configure cgit in /etc/cgitrc:
#
# cgit config
# see cgitrc(5) for details
css=/cgit/cgit.css
logo=/cgit/cgit.png
favicon=/cgit/favicon.ico
enable-index-links=1
enable-commit-graph=1
enable-log-filecount=1
enable-log-linecount=1
enable-git-config=1
branch-sort=age
repository-sort=name
clone-url=https://git.hyperreal.coffee/$CGIT_REPO_URL git://git.hyperreal.coffee/$CGIT_REPO_URL ssh://git@git.hyperreal.coffee:$CGIT_REPO_URL
root-title=hyperreal.coffee Git repositories
root-desc=Source code and configs for my projects
##
## List of common mimetypes
##
mimetype.gif=image/gif
mimetype.html=text/html
mimetype.jpg=image/jpeg
mimetype.jpeg=image/jpeg
mimetype.pdf=application/pdf
mimetype.png=image/png
mimetype.svg=image/svg+xml
# Enable syntax highlighting
source-filter=/usr/lib/cgit/filters/syntax-highlighting.py
# Format markdown, rst, manpages, text files, html files, and org files.
about-filter=/usr/lib/cgit/filters/about-formatting.sh
##
### Search for these files in the root of the default branch of repositories
### for coming up with the about page:
##
readme=:README.md
readme=:README.org
robots=noindex, nofollow
section=personal-config
repo.url=doom-emacs-config
repo.path=/home/git/repositories/doom-emacs-config.git
repo.desc=My Doom Emacs config
Setting up Restic with rest-server
Context
I recently decided to start using my own home server to store my dotfiles. The main reasons are simplicity, privacy, and security. I previously stored them in a repository on my GitHub account and installed them with Ansible, but I have increasingly found it cumbersome when trying to keep them updated and in sync. On GitHub, the changes (and mistakes!) I make to my dotfiles are publicly viewable; sometimes I’ll make changes several times a day, sometimes scrapping a change entirely when I later realize it was not such a good idea or breaks something in my activity flow. I also would love the convenience of keeping SSH keys and GPG keychains in sync and updated, and storing them on a public server is obviously not an option, nor even in a private repository hosted on GitHub or GitLab.
Cue Restic
My home server is basically just my old 2013 MacBook Pro running Fedora Server edition. It has a 250GB SSD, which is more than enough for what I need. I also have a 1TB external SSD which I will use to emulate redundancy. I installed and configure the rest-server software to act as a backend for my Restic backups.
Setting up the rest server
First build the rest-server binary and move it to a directory in PATH. This step requires Go 1.11 or higher. Optionally, you can download the latest compiled rest-server binary from its releases page.
* GitHub :: restic/rest-server/releases
git clone https://github.com/restic/rest-server
cd rest-server/
CGO_ENABLED=0 go build -o rest-server ./cmd/rest-server
sudo cp -v rest-server /usr/local/bin/
I also configured the systemd unit file so that rest-server runs on startup with the appropriate flags. I need only configure the options User, Group, ExecStart, and ReadWritePaths in the [Service] section:
cd ~/rest-server/examples/systemd/
ls .
rest-server.service:
[Service]
Type=simple
User=restic-data
Group=restic-data
ExecStart=/usr/local/bin/rest-server --path /opt/restic-backups --no-auth
Restart=always
RestartSec=5
# Optional security enhancements
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/opt/restic-backups
Since this is a local home server, I pass the --no-auth flag to the rest-server ExecStart command.
I now create the restic-data user and group.
- Ensure a default home directory is not created under /home by passing the
-Mflag. - Set a custom home directory for the user at /opt/restic-backups with the
-dflag. - Ensure the shell is assigned to
/sbin/nologin. - The restic-data user is not meant to be used for logging in, so we pass the
--systemflag.
sudo useradd -c "Restic Data" -M -d /opt/restic-backups -s /sbin/nologin --system restic-data
- Ensure the backups path exists and has appropriate permissions.
- Copy the systemd unit file to a location where systemd will look for it.
- Enable and start the rest-server systemd service.
sudo mkdir /opt/restic-backups
sudo chown -R restic-data:restic-data /opt/restic-backups
sudo cp -v rest-server.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now rest-server.service
Since I’m using a firewall, I ensure the port the rest-server listens on is allowed locally:
sudo firewall-cmd --zone`FedoraServer --permanent --add-port`8000/tcp
sudo firewall-cmd --reload
Now on the host, which in this case is my laptop, I have the Restic client installed from my distribution’s package repository.
- Initialize a Restic storage repository on the server from the host, and supply it with a password. This password will be used every time I attempt to access the storage repository.
- Backup my dotfiles
restic -r rest:http://local-server:8000/dotfiles init
restic -r rest:http://local-server:8000/dotfiles backup ~/dotfiles
One of the best features of Restic is that it makes restory backups really simple. It also provides snapshot functionality, so I can restore different versions of specific files from other snapshots.
restic -r rest:http://local-server:8000/dotfiles snapshots
enter password for repository:
repository 9a280eb7 opened successfully, password is correct
ID Time Host Tags Paths
------------------------------------------------------------------------------
11738fec 2021-04-12 09:13:17 toolbox /var/home/jeff/dotfiles
dfc99aa3 2021-04-12 10:31:39 toolbox /var/home/jeff/dotfiles
f951eedf 2021-04-12 11:25:21 toolbox /var/home/jeff/dotfiles
62371897 2021-04-12 18:43:53 toolbox /var/home/jeff/dotfiles
------------------------------------------------------------------------------
4 snapshots
Since Restic saves the backup’s absolute path, restoring it to / will ensure it is restored to its original location on the local filesystem. To restore a snapshot:
restic -r rest:http://local-server:8000/dotfiles restore dfc99aa3 --target /
To list files in a snapshot:
restic -r rest:http://local-server:8000/dotfiles ls dfc99aa3
Yay, very nice!
Resources
Bluesky PDS with Podman on CentOS Stream 10
This is based on the following documentation from the official Bluesky GitHub:
We’ll keep SELinux in enforcing mode and install a policy module to allow the PDS to work. CentOS Stream is not an officially supported distribution by the upstream PDS maintainers – this is my own working setup – so please do not bother them with support questions for a CentOS Stream host. In lieu of that, you’re welcome to direct any questions or issues with this setup to me at @hyperreal@tilde.zone in the fediverse, or submit an issue at github.com/bluesky-social/deploy-recipes.
Minimum server requirements
| Component | Requirement |
|---|---|
| OS | CentOS Stream 10 |
| RAM | 1 GB |
| CPU cores | 1 |
| Storage | 20 GB SSD |
| Architectures | amd64, arm64 |
| Number of PDS users | 1-20 |
Ensure you have a firewall installed along with fail2ban, and that proper security precautions are taken for your server, such as SSH hardening.
Other requirements:
- Public IPv4 address
- Public DNS name
- Public inbound internet access permitted on port 80/tcp and 443/tcp
- A reverse-proxy server, such as Caddy. This guide assumes you have Caddy installed.
Preliminary actions
Install Podman and epel-release.
sudo dnf install '@container-management' epel-release
SELinux policy module
SELinux is not required, but it is recommended for RHEL-like distributions. To set SELinux in enforcing mode, run the following command as root.
setenforce 1
To make this persist across reboots, you may need to edit /etc/sysconfig/selinux. Change the SELINUX variable to the value enforcing. You can do this with the following command, which is idempotent if it is already set to enforcing.
sudo sed -i 's/SELINUX=permissive/SELINUX=enforcing/' /etc/sysconfig/selinux
You also need to install an SELinux policy module so that SELinux doesn’t deny the container processes used by the PDS.
Create the file pds.te.
module pds 1.0;
require {
type container_runtime_t;
type var_run_t;
type container_t;
type default_t;
class file { create lock map open read setattr unlink write };
class dir { add_name remove_name write };
class unix_stream_socket connectto;
class sock_file write;
}
# ============= container_t ==============
allow container_t container_runtime_t:unix_stream_socket connectto;
allow container_t default_t:dir { add_name remove_name write };
allow container_t default_t:file { create lock map open read setattr unlink write };
allow container_t var_run_t:sock_file write;
Compile and install the module.
checkmodule -M -m -o pds.mod pds.te
semodule_package -o pds.pp -m pds.mod
sudo semodule -i pds.pp
If SELinux still denies some processes, you can check for them with the following command.
sudo ausearch -m avc -ts recent | sudo audit2allow
Installing the PDS Podman quadlet
The systemd quadlet files are taken from github.com/bluesky-social/deploy-recipes. The only modification I made is the EnvironmentFile setting.
The directory under which you should place these files is /etc/containers/systemd.
Create the file /etc/containers/systemd/pds.container.
[Unit]
Description=Bluesky Personal Data Server service
Before=caddy.service
[Container]
Label=app=pds
Image=ghcr.io/bluesky-social/pds:0.4
AutoUpdate=registry
Pod=pds.pod
EnvironmentFile=/etc/pds.env
[Install]
WantedBy=multi-user.target default.target
Next, create the file /etc/containers/systemd/pds.pod.
[Pod]
Volume=pds.volume:/pds
PublishPort=127.0.0.1:3000:3000
# if you map 3000:3000 instead of 127.0.0.1:3000:3000
# the PDS will be accessible without the reverse proxy. You probably don't want that!
Finally, create the file /etc/containers/systemd/pds.volume.
[Unit]
Description=Bluesky PDS Volume
[Volume]
Label=app=pds
These files comprise a systemd quadlet. Quadlets enable Podman containers to run as systemd services. It’s a declarative way to configure containers that fits in with the systemd ecosystem.
More information on how to configure quadlets can be found here: podman-systemd.unit.
It’s necessary to have all of these files together in the same directory, as they depend on each other.
Setting up pds.env
Here is the default pds.env. You should edit it to your specific use.
PDS_HOSTNAME=pds.example.com
PDS_JWT_SECRET=
PDS_ADMIN_PASSWORD=
PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=
PDS_DATA_DIRECTORY=/pds #mapped to volume
PDS_BLOBSTORE_DISK_LOCATION=/pds/blocks
PDS_BLOB_UPLOAD_LIMIT=104857600
# if you want to use s3 or compatible, use these variables and comment DISK_LOCATION
# Object Storage
PDS_BLOBSTORE_S3_BUCKET=your-bucket-name
PDS_BLOBSTORE_S3_ENDPOINT=https://s3.example.com
# PDS_BLOBSTORE_S3_FORCE_PATH_STYLE=true #depends on your provider
PDS_BLOBSTORE_S3_ACCESS_KEY_ID=your-access-key-id
PDS_BLOBSTORE_S3_REGION=your-region
PDS_BLOBSTORE_S3_SECRET_ACCESS_KEY=your-secret-key
PDS_DID_PLC_URL=https://plc.directory
PDS_BSKY_APP_VIEW_URL=https://api.bsky.app
PDS_BSKY_APP_VIEW_DID=did:web:api.bsky.app
PDS_REPORT_SERVICE_URL=https://mod.bsky.app
PDS_REPORT_SERVICE_DID=did:plc:ar7c4by46qjdydhdevvrndac
PDS_CRAWLERS=https://bsky.network
LOG_ENABLED=true
PDS_EMAIL_SMTP_URL=
PDS_EMAIL_FROM_ADDRESS=
You should set PDS_JWT_SECRET to a pseudo-random value generated with the following command.
openssl rand --hex 16
Likewise for PDS_ADMIN_PASSWORD.
openssl rand --hex 16
You also need to set PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX to the value generated with this command.
openssl ecparam --name secp256k1 --genkey --noout --outform DER | tail --bytes=+8 | head --bytes=32 | xxd --plain --cols 32
As per the EnvironmentFile setting in pds.container, we should store pds.env at /etc/pds.env.
Starting the PDS
The following systemd command will load the files we placed under /etc/containers/systemd/ and generate systemd unit files from them.
sudo systemctl daemon-reload
You should now be able to query pds.service by running the following command. Note that it will show the unit as inactive until it is started.
sudo systemctl status pds.service
Now we can start the units.
sudo systemctl start pds.service
This should pull in the PDS container image and start it. You can check the status with sudo systemctl status pds.service.
Caddy configuration
A valid Caddy configuration should look like this.
{
email myemail@example.com
on_demand_tls {
ask http://localhost:3000/tls-check
}
}
# PDS
*.pds.example.com, pds.example.com {
tls {
on_demand
}
reverse_proxy <http://localhost:3000>
}
# Anything else for your server
pdsadmin.sh
You can create pdsadmin.sh and put it somewhere in your system’s binary PATH, such as /usr/local/bin/pdsadmin.sh.
#!/usr/bin/env bash
set -o errexit
set -o nounset
set -o pipefail
PDSADMIN_BASE_URL="https://raw.githubusercontent.com/bluesky-social/pds/main/pdsadmin"
export PDS_ENV_FILE="/etc/pds.env"
# Command to run
COMMAND="${1:-help}"
shift || true
# we don't actually need root here since it only is required
# Download the script, if it exists
SCRIPT_URL="${PDSADMIN_BASE_URL}/${COMMAND}.sh"
SCRIPT_FILE="$(mktemp /tmp/pdsadmin.${COMMAND}.XXXXXX)"
if [[ "${COMMAND}" == "update" ]]; then
echo "ERROR: self-update not supported via podman"
exit 1
fi
if ! curl --fail --silent --show-error --location --output "${SCRIPT_FILE}" "${SCRIPT_URL}"; then
echo "ERROR: ${COMMAND} not found"
exit 2
fi
chmod +x "${SCRIPT_FILE}"
if "${SCRIPT_FILE}" "$@"; then
rm -f "${SCRIPT_FILE}"
fi
Make the file executable.
sudo chmod +x /usr/local/bin/pdsadmin.sh
You can now run pdsadmin.sh with no arguments to see usage info. You’ll of course need to create an account on your PDS.
Verifying your PDS is online and accessible
Visit <https://your-domain.net/xrpc/_health> in your browser. Or run the following command in your terminal.
curl https://your-domain.net/xrpc/_health
You should receive a JSON response with a version, similar to this.
{"version":"0.4.204"}
You’ll also need to check that WebSockets are working. You can do this with the wsdump tool. You’ll need the latest version of Golang to install it.
sudo dnf install golang
Now to install the wsdump tool:
go install github.com/nrxr/wsdump@latest
Then run it:
wsdump "wss://your-domain.net/xrpc/com.atproto.sync.subscribeRepos?cursor=0"
Note that there will be no events on the WebSocket until they are created in the PDS, so the above command may continue to run with no output. You’ll have to press CTRL-C to stop it.
Closing
That’s how to setup a Bluesky PDS on CentOS Stream 10. Additionally, this setup should also work on AlmaLinux 10, Rocky Linux 10, and Fedora 43, but will not work on any earlier versions of those distributions. I recommend reading the README.md at github.com/bluesky-social/pds for more information on using the pdsadmin command, setting up SMTP, and troubleshooting.
If you have any questions or issues with this setup, feel free to reach out to me at @hyperreal@tilde.zone. You may also submit an issue at github.com/bluesky-social/deploy-recipes, and either I or someone else will help troubleshoot the issue.
Self-hosted container registry with web UI
Source: <https://github.com/Joxit/docker-registry-ui>
Docker/Podman compose
services:
registry-ui:
image: joxit/docker-registry-ui:main
restart: always
ports:
- "127.0.0.1:4433:80"
environment:
- SINGLE_REGISTRY=true
- REGISTRY_TITLE=hyperreal's Container Registry
- DELETE_IMAGES=true
- SHOW_CONTENT_DIGEST=true
- NGINX_PROXY_PASS_URL=http://registry-server:5000
- SHOW_CATALOG_NB_TAGS=true
- CATALOG_MIN_BRANCHES=1
- CATALOG_MAX_BRANCHES=1
- TAGLIST_PAGE_SIZE=100
- REGISTRY_SECURED=false
- CATALOG_ELEMENTS_LIMIT=1000
container_name: registry-ui
registry-server:
image: registry:2.8.2
restart: always
environment:
REGISTRY_HTTP_HEADERS_Access-Control-Allow-Origin: '[http://aux-remote.carp-wyvern.ts.net]'
REGISTRY_HTTP_HEADERS_Access-Control-Allow-Methods: '[HEAD,GET,OPTIONS,DELETE]'
REGISTRY_HTTP_HEADERS_Access-Control-Allow-Credentials: '[true]'
REGISTRY_HTTP_HEADERS_Access-Control-Allow-Headers: '[Accept,Cache-Control]'
REGISTRY_HTTP_HEADERS_Access-Control-Expose-Headers: '[Docker-Content-Digest]'
REGISTRY_STORAGE_DELETE_ENABLED: 'true'
volumes:
- ./registry/data:/var/lib/registry
container_name: registry-server
Authorization and Authentication
For a public registry with authentication, the following headers are required:
environment:
REGISTRY_HTTP_HEADERS_Access-Control-Allow-Headers: '[Authorization,Accept,Cache-Control]'
For a private registry without authentication, the following headers are required:
environment:
REGISTRY_HTTP_HEADERS_Access-Control-Allow-Headers: '[Accept,Cache-Control]'
Caddy reverse proxy
Public registry
registry.hyperreal.coffee {
reverse_proxy localhost:4433
}
Private registry via Tailnet
aux-remote.carp-wyvern.ts.net {
reverse_proxy localhost:4433
}
Ensure the following is added to /etc/default/tailscaled:
TS_PERMIT_CERT_UID=caddy
The above will ensure Caddy receives SSL certs from the Tailscale daemon.
Using Codeberg, Gitea, or Forgejo as OIDC provider for Tailscale
Requirements
- An account on Codeberg, Gitea instance, or Forgejo instance.
- A domain name. E.g., I use moonshadow.dev.
- The primary email address on your Codeberg, Gitea, or Forgejo account must be from the above domain name. E.g., mine is hyperreal@moonshadow.dev.
- A publicly accessible web server to host your webfinger file. You could also use Codeberg Pages for this with your custom domain. The web server must serve content at your domain. E.g., <https://moonshadow.dev>.
Webfinger
In the web root of your web server, create the .well-known/webfinger file. For example, on mine, I have the following:
{
"subject": "acct:hyperreal@moonshadow.dev",
"links": [
{
"rel": "http://openid.net/specs/connect/1.0/issuer",
"href": "https://codeberg.org"
}
]
}
You can use the Webfinger lookup tool to make sure it is setup correctly.
The value of the “subject” field must contain the email address at the domain you own. The value of the “href” field must be the URL of the Codeberg, Gitea instance, or Forgejo instance.
Create an OAuth2 application on Codeberg, Gitea, or Forgejo
On Codeberg, Gitea, or Forgejo, go to your User Settings -> Applications -> Manage OAuth2 applications.
- Application name: tailscale
- Redirect URI:
<https://login.tailscale.com/a/oauth_response> - Confidential client: checked
Click on Create. Now copy and save the Client ID and Client secret that was generated.
Sign up with Tailscale
- Go to the Tailscale login page, and select “Sign up with OIDC”.
- Enter your email at your custom domain. E.g., hyperreal@moonshadow.dev.
- Choose Codeberg or Gitea as the identity provider. This step is optional and doesn’t really matter. Forgejo instances can choose Gitea.
- Select “Get OIDC Issuer”.
- Enter the Client ID and Client secret you saved from your OAuth2 application. Leave everything else as default, and make sure that “consent” is checked under Prompts.
- Click “Sign up with OIDC”, and you should be able to login to Tailscale and be redirected to your Tailscale admin console.
By using the “Human Made” badge, I affirm that I am not using data-generated images (“genAI”) or data-generated text (“genAI”) in any asset of my work. My workflow consists of only using my skill crafted from experience and practice.