Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Index

no genAI, human made 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/zen file 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.

resolvectl status

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.service should be disabled, and /etc/resolv.conf should 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 TypeSize
EFI+600M
boot+900M
LinuxRemaining 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/nvme0n1p1 EFI System Partition (EF00)
  • 100%+ /dev/nvme0n1p2 Linux 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 createrepo command 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 -M flag.
  • Set a custom home directory for the user at /opt/restic-backups with the -d flag.
  • 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 --system flag.
  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

ComponentRequirement
OSCentOS Stream 10
RAM1 GB
CPU cores1
Storage20 GB SSD
Architecturesamd64, arm64
Number of PDS users1-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

  1. Go to the Tailscale login page, and select “Sign up with OIDC”.
  2. Enter your email at your custom domain. E.g., hyperreal@moonshadow.dev.
  3. Choose Codeberg or Gitea as the identity provider. This step is optional and doesn’t really matter. Forgejo instances can choose Gitea.
  4. Select “Get OIDC Issuer”.
  5. 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.
  6. Click “Sign up with OIDC”, and you should be able to login to Tailscale and be redirected to your Tailscale admin console.

Resources