<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom"><title>s3lph made</title><link href="https://s3lph.me/" rel="alternate"/><link href="https://s3lph.me/feeds/atom.xml" rel="self"/><id>https://s3lph.me/</id><updated>2026-06-19T23:00:00+02:00</updated><entry><title>Building AUR Packages with Forgejo Actions and Renovate</title><link href="https://s3lph.me/building-aur-packages-with-forgejo-actions-and-renovate.html" rel="alternate"/><published>2026-06-19T23:00:00+02:00</published><updated>2026-06-19T23:00:00+02:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2026-06-19:/building-aur-packages-with-forgejo-actions-and-renovate.html</id><summary type="html">&lt;p&gt;The &lt;a href="https://aur.archlinux.org/"&gt;Arch User Repository&lt;/a&gt; (AUR) is a collection of user-contributed source packages for &lt;a href="https://archlinux.org/"&gt;Arch Linux&lt;/a&gt;.
These packages, which may be submitted by anyone without review, simply consist of package metadata, and instructions for downloading and building software from source.
This also means that special care must be taken when using …&lt;/p&gt;</summary><content type="html">&lt;p&gt;The &lt;a href="https://aur.archlinux.org/"&gt;Arch User Repository&lt;/a&gt; (AUR) is a collection of user-contributed source packages for &lt;a href="https://archlinux.org/"&gt;Arch Linux&lt;/a&gt;.
These packages, which may be submitted by anyone without review, simply consist of package metadata, and instructions for downloading and building software from source.
This also means that special care must be taken when using AUR packages; while they can be extremely useful, they may also contain malware as became apparent once more &lt;a href="https://archlinux.org/news/active-aur-malicious-packages-incident/"&gt;recently&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The "recommended" way to build an AUR package is to checkout the package source repository, review its contents and then build the package using Arch's &lt;code&gt;makepkg&lt;/code&gt; package build tool.
Since this can be a rather tedious process, various different AUR build helper tools exist, however usage of them is not recommended, as they often cause various hard-to-troubleshoot issues.
Having been a long-time user of such AUR helpers, and regularly running into the aforementioned problems, I wanted to find a better (and more effortless) solution for building AUR packages.&lt;/p&gt;
&lt;h2 id="forgejo-to-the-rescue"&gt;Forgejo to the Rescue!&lt;/h2&gt;
&lt;p&gt;The open-source git forge &lt;a href="https://forgejo.org/"&gt;Forgejo&lt;/a&gt;, which we also run at our hackerspace, comes with an &lt;a href="https://forgejo.org/docs/latest/user/packages/arch/"&gt;Arch Linux package registry&lt;/a&gt;.
This registry can simply be added to the list of software repositories in an Arch Linux installation's &lt;code&gt;/etc/pacman.conf&lt;/code&gt;, and compiled packages can be pushed to it via a REST API endpoint.&lt;/p&gt;
&lt;p&gt;To build the AUR packages I need, I set up a &lt;a href="https://git.kabelsalat.ch/s3lph/package-pipeline-aur/src/branch/main/.forgejo/workflows/package.yml"&gt;Forgejo Actions workflow&lt;/a&gt;, which builds these packages at least once a week or when changes are pushed to the repository:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="c1"&gt;# Build on commits to main and once a week&lt;/span&gt;
&lt;span class="nt"&gt;on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;push&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;branches&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;main&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;quot;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;6&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1&amp;quot;&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="c1"&gt;# every Monday at 6 AM&lt;/span&gt;

&lt;span class="nt"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;

&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="c1"&gt;# One step per package, for other packages just do `packagename: *job`&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;sslyze&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;&amp;amp;job&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;runs-on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;docker&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;container&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="c1"&gt;# base-devel is implicitly required for package building. &lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="c1"&gt;# Any other dependencies must be declared explicitly.&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;docker.io/library/archlinux:base-devel&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="c1"&gt;# Secrets required to push to the package registry&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;API_REPOSITORY_ARCH&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${{ secrets.API_REPOSITORY_ARCH }}&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;API_REPOSITORY_ARCH_DEBUG&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${{ secrets.API_REPOSITORY_ARCH_DEBUG }}&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;API_USERNAME&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${{ secrets.API_USERNAME }}&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;API_PASSWORD&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${{ secrets.API_PASSWORD }}&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;

&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;Prepare build pipeline&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p p-Indicator"&gt;|&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="no"&gt;# nodejs and git are needed for the checkout action; unlike the default image,&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="no"&gt;# `docker.io/library/ubuntu`, these are not shipped by default in `archlinux:base-devel`.&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="no"&gt;pacman --noconfirm -Sy nodejs git&lt;/span&gt;

&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;Checkout repository&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="c1"&gt;# This only checks out the project repo, not the submodules&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;https://code.forgejo.org/actions/checkout@v7&lt;/span&gt;

&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;Build and upload package&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p p-Indicator"&gt;|&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="c1"&gt;# Add AUR repo itself for makedepends from AUR&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;curl https://git.kabelsalat.ch/api/packages/s3lph/arch/repository.key &amp;gt; /sign.gpg&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;pacman-key --init&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;pacman-key --add /sign.gpg&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;pacman-key --lsign-key &amp;#39;s3lph@noreply.git.kabelsalat.ch&amp;#39;&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;cat &amp;gt;&amp;gt; /etc/pacman.conf &amp;lt;&amp;lt;EOF&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;[aur]&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;SigLevel = Required&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;Server = https://git.kabelsalat.ch/api/packages/s3lph/arch/aur/\$arch&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;EOF&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;pacman -Sy&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="c1"&gt;# Checkout only the submodule of the package to build in this job&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;git submodule update --init &amp;quot;${FORGEJO_JOB}&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;chown nobody&lt;/span&gt;&lt;span class="p p-Indicator"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;-R &amp;quot;${FORGEJO_JOB}&amp;quot; &amp;amp;&amp;amp; cd &amp;quot;${FORGEJO_JOB}&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="c1"&gt;# Check if the PKGBUILD contains run-time or compile-time dependencies, and install if necessary&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;(source ./PKGBUILD &amp;amp;&amp;amp; [[ -n &amp;quot;${depends[@]}${makedepends[@]}&amp;quot; ]] &amp;amp;&amp;amp; pacman --noconfirm -S ${depends[@]} ${makedepends[@]} || true)&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="c1"&gt;# Build the binary package(s) from this source package&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;sudo -E -u nobody -- makepkg --cleanbuild&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="c1"&gt;# All debug packages are uploaded to the separate aur-debug registry&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;for file in *-debug-*pkg.tar*; do&lt;/span&gt;
&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;curl -X PUT &amp;quot;${API_REPOSITORY_ARCH_DEBUG}&amp;quot; \&lt;/span&gt;
&lt;span class="w"&gt;              &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;--user &amp;quot;${API_USERNAME}:${API_PASSWORD}&amp;quot; \&lt;/span&gt;
&lt;span class="w"&gt;              &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;--header &amp;quot;Content-Type&lt;/span&gt;&lt;span class="p p-Indicator"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;application/octet-stream&amp;quot; \&lt;/span&gt;
&lt;span class="w"&gt;              &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;--data-binary @&amp;quot;${file}&amp;quot; || true&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;done&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;rm -f *-debug-*pkg.tar*&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="c1"&gt;# The regular packages are uploaded to the `aur` registry&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;for file in *pkg.tar*; do&lt;/span&gt;
&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;curl -X PUT &amp;quot;${API_REPOSITORY_ARCH}&amp;quot; \&lt;/span&gt;
&lt;span class="w"&gt;              &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;--user &amp;quot;${API_USERNAME}:${API_PASSWORD}&amp;quot; \&lt;/span&gt;
&lt;span class="w"&gt;              &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;--header &amp;quot;Content-Type&lt;/span&gt;&lt;span class="p p-Indicator"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;application/octet-stream&amp;quot; \&lt;/span&gt;
&lt;span class="w"&gt;              &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;--data-binary @&amp;quot;${file}&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;h2 id="updating-packages-with-renovate"&gt;Updating Packages with Renovate&lt;/h2&gt;
&lt;p&gt;So now I've got a pipeline which builds the AUR packages I need on a regular basis, and a repository where these packages are pushed to.
However, I've still got the problem of reviewing all changes to AUR packages to ensure no malicious code is introduced.&lt;/p&gt;
&lt;p&gt;To solve this, I added all the AUR package repositories I need to my pipeline repository as &lt;a href="https://git-scm.com/book/en/v2/Git-Tools-Submodules"&gt;git submodules&lt;/a&gt;.
These submodules are pinned to a fixed git commit id, so I'm not pulling in the latest changes unreviewed, but always build a package for which I know I have reviewed the build instructions.&lt;/p&gt;
&lt;p&gt;To receive updates to these submodules, I'm using the dependency management tool &lt;a href="https://docs.renovatebot.com/"&gt;Renovate&lt;/a&gt;, which runs as another Forgejo Actions workflow in another repository on the same Forgejo server.
Once a day, Renovate checks whether there are any kind of dependency updates which would need to be applied to repositories it monitors.
Among many other types of dependencies, Renovate also supports checking whether the commit a git submodule points to is still the latest upstream commit.
If any such "outdated" dependency is found, Renovate opens a pull request against the repository, and assigns it to the owner for review.&lt;/p&gt;
&lt;p&gt;However, these pull requests only consist of a changed submodule commit id, so review of the acutal changes is not as trivial:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="gd"&gt;--- a/sslyze&lt;/span&gt;
&lt;span class="gi"&gt;+++ b/sslyze&lt;/span&gt;
&lt;span class="gu"&gt;@@ -1 +1 @@&lt;/span&gt;
&lt;span class="gd"&gt;-Subproject commit 848023a5347b8af959de3287d65eb12b0fc341e2&lt;/span&gt;
&lt;span class="gi"&gt;+Subproject commit d78430b2aad33c00ca047f4f498d61ef9a25566e&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;The last piece of the puzzle is &lt;a href="https://git.kabelsalat.ch/s3lph/package-pipeline-aur/src/branch/main/.forgejo/workflows/pr-diff.yml"&gt;yet another Forgejo Actions workflow&lt;/a&gt;, which is triggered by these pull requests:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="c1"&gt;# Run whenever a PR is opened or changed&lt;/span&gt;
&lt;span class="nt"&gt;on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;pull_request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;types&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;opened&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;synchronize&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;reopened&lt;/span&gt;

&lt;span class="nt"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;

&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;runs-on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;docker&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;

&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;Checkout repository&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;https://code.forgejo.org/actions/checkout@v6&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;with&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="nt"&gt;submodules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;true&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="nt"&gt;fetch-depth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;0&lt;/span&gt;

&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;Compute diff and post comment&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p p-Indicator"&gt;|&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="no"&gt;apt update; apt install --yes python3-all&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="c1"&gt;# the comment.py script just posts the output of `git diff --submodule=diff`&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="c1"&gt;# as a comment to the PR, or updates an existing comment.&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;python3 .forgejo/comment.py&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;This workflow checks out the pipeline repository including all submodules, and computes the PR diff including the submodules.
The resulting diff is then posted as a comment to the pull request, which allows me to easily review the changes:&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2026-06-19-building-aur-packages-with-forgejo-actions-comment.png"&gt;
    &lt;img src="https://s3lph.me/images/2026-06-19-building-aur-packages-with-forgejo-actions-comment.png"
         title="Screenshot of a diff commented to a pull request by Forgejo Actions"
         alt="Screenshot of a diff commented to a pull request by Forgejo Actions"&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure1" class="figureid"&gt;Figure 1:&lt;/a&gt;
    Screenshot of a diff commented to a pull request by Forgejo Actions
  &lt;/figcaption&gt;
&lt;/figure&gt;</content><category term="hacks"/><category term="Arch Linux"/><category term="AUR"/><category term="Forgejo"/><category term="Software Packaging"/><category term="Continuous Delivery"/></entry><entry><title>Unified Kernel Images and Secure Boot using Arch Linux</title><link href="https://s3lph.me/unified-kernel-images-and-secure-boot-using-arch-linux.html" rel="alternate"/><published>2025-09-17T00:00:00+02:00</published><updated>2025-09-17T00:00:00+02:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2025-09-17:/unified-kernel-images-and-secure-boot-using-arch-linux.html</id><summary type="html">&lt;p&gt;I recently got a new laptop computer and decided to give &lt;a href="https://wiki.archlinux.org/title/Unified_kernel_image"&gt;Unified Kernel Images&lt;/a&gt; and &lt;a href="https://wiki.archlinux.org/title/Unified_Extensible_Firmware_Interface/Secure_Boot"&gt;Secure Boot&lt;/a&gt; with my own keys another try.
When I last looked into these topics almost a decade ago, they were extremely cumbersome to set up and maintain, and required lots of manual steps.
A …&lt;/p&gt;</summary><content type="html">&lt;p&gt;I recently got a new laptop computer and decided to give &lt;a href="https://wiki.archlinux.org/title/Unified_kernel_image"&gt;Unified Kernel Images&lt;/a&gt; and &lt;a href="https://wiki.archlinux.org/title/Unified_Extensible_Firmware_Interface/Secure_Boot"&gt;Secure Boot&lt;/a&gt; with my own keys another try.
When I last looked into these topics almost a decade ago, they were extremely cumbersome to set up and maintain, and required lots of manual steps.
A lot has changed since, and most of the steps have been automated, so that only little configuration and manual steps are required.&lt;/p&gt;
&lt;p&gt;Please note that &lt;strong&gt;none of this is original work by me&lt;/strong&gt;; it's mainly taken from the Arch Linux wiki articles linked above.
However, since there are multiple, partially conflicting ways for setting up either, I wanted to document here which methods I used.
As far as I'm aware, this is also the method that requires the least amount of custom configuration.&lt;/p&gt;
&lt;h2 id="efi-partition-layout"&gt;EFI Partition Layout&lt;/h2&gt;
&lt;p&gt;I'm mounting my EFI system partition (ESP) under &lt;code&gt;/efi&lt;/code&gt;, as this is one of the supported default locations in this setup; other locations may require additional configuration.&lt;/p&gt;
&lt;p&gt;Inside the ESP, I'm placing all images in &lt;code&gt;/EFI/arch&lt;/code&gt;, so e.g. the primary UKI would be placed at &lt;code&gt;/efi/EFI/arch/arch.efi&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="unified-kernel-image"&gt;Unified Kernel Image&lt;/h2&gt;
&lt;p&gt;When directly booting UKI's, there is no boot loader such as GRUB2 involved.  Therefore, the kernel command line needs to be baked into the UKI.
You can place your cmdline in &lt;code&gt;/etc/cmdline.d/*.conf&lt;/code&gt;; I placed the command line for unlocking and mounting the root volume in &lt;code&gt;/etc/cmdline.d/root.conf&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="n"&gt;cryptdevice&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="n"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="n"&gt;6b50e908&lt;/span&gt;&lt;span class="p"&gt;-&lt;/span&gt;&lt;span class="n"&gt;8270&lt;/span&gt;&lt;span class="p"&gt;-&lt;/span&gt;&lt;span class="n"&gt;49d9&lt;/span&gt;&lt;span class="p"&gt;-&lt;/span&gt;&lt;span class="n"&gt;8b31&lt;/span&gt;&lt;span class="p"&gt;-&lt;/span&gt;&lt;span class="n"&gt;12444502670d&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;crypt&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="n"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="n"&gt;8808fd0b&lt;/span&gt;&lt;span class="p"&gt;-&lt;/span&gt;&lt;span class="n"&gt;16d3&lt;/span&gt;&lt;span class="p"&gt;-&lt;/span&gt;&lt;span class="n"&gt;4531-af25&lt;/span&gt;&lt;span class="p"&gt;-&lt;/span&gt;&lt;span class="n"&gt;510b1d9c399f&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;To actually build the UKI, you also need to modify the mkinitcpio preset &lt;code&gt;/etc/mkinitcpio.d/linux.preset&lt;/code&gt; by commenting out the &lt;code&gt;*_image&lt;/code&gt; lines, and instead uncommenting the &lt;code&gt;*_uki&lt;/code&gt; lines:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="c1"&gt;# ...&lt;/span&gt;
&lt;span class="c1"&gt;#default_image=&amp;quot;/boot/initramfs-linux.img&amp;quot;&lt;/span&gt;
&lt;span class="nv"&gt;default_uki&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;/efi/EFI/arch/arch.efi&amp;quot;&lt;/span&gt;
&lt;span class="c1"&gt;# ...&lt;/span&gt;

&lt;span class="c1"&gt;# ...&lt;/span&gt;
&lt;span class="c1"&gt;#fallback_image=&amp;quot;/boot/initramfs-linux-fallback.img&amp;quot;&lt;/span&gt;
&lt;span class="nv"&gt;fallback_uki&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;/efi/EFI/arch/arch-fallback.efi&amp;quot;&lt;/span&gt;
&lt;span class="c1"&gt;# ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;If you're using another preset file, e.g. for &lt;code&gt;linux-lts&lt;/code&gt;, you need to apply the same config change to this file as well.&lt;/p&gt;
&lt;p&gt;Now you can build your UKI's with &lt;code&gt;mkinitcpio -p linux&lt;/code&gt;.  Among the output you should see the following lines:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="gp"&gt;# &lt;/span&gt;mkinitcpio&lt;span class="w"&gt; &lt;/span&gt;-p&lt;span class="w"&gt; &lt;/span&gt;linux
&lt;span class="go"&gt;...&lt;/span&gt;
&lt;span class="go"&gt;==&amp;gt; Initcpio image generation successful&lt;/span&gt;
&lt;span class="go"&gt;==&amp;gt; Creating unified kernel image: &amp;#39;/efi/EFI/arch/arch.efi&amp;#39;&lt;/span&gt;
&lt;span class="go"&gt;  -&amp;gt; Using ukify to build UKI&lt;/span&gt;
&lt;span class="go"&gt;  -&amp;gt; Using cmdline file: &amp;#39;/etc/cmdline.d/root.conf&amp;#39;&lt;/span&gt;
&lt;span class="go"&gt;Using config file: /usr/lib/kernel/uki.conf&lt;/span&gt;
&lt;span class="go"&gt;Wrote unsigned /efi/EFI/arch/arch.efi&lt;/span&gt;
&lt;span class="go"&gt;==&amp;gt; Unified kernel image generation successful&lt;/span&gt;
&lt;span class="go"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;You should now be able to boot using this UKI.  To set this up in your firmware, complete the following steps:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Reboot into firmware setup.  If your system supports it, you can use &lt;code&gt;systemctl reboot --firmware-setup&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Once in the firmware setup, add an UEFI boot entry for &lt;code&gt;/EFI/arch/arch.efi&lt;/code&gt;.  You may also want to add one for &lt;code&gt;arch-fallback.efi&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Since the UKI is not signed yet, make sure that Secure Boot is disabled.  At this point you can also delete the Secure Boot keys to put Secure Boot into Setup Mode.&lt;/li&gt;
&lt;li&gt;Save your firmware settings and reboot.  If everything went right, the UKI should be booted.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="secure-boot"&gt;Secure Boot&lt;/h2&gt;
&lt;p&gt;Setting up Secure Boot has become almost trivial in the last few years through the help of tools such as &lt;code&gt;sbctl&lt;/code&gt;.
For this, sbctl needs to be installed via &lt;code&gt;pacman -S sbctl&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;To verify that the system is ready for Secure Boot enrollment, run &lt;code&gt;sbctl status&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="gp"&gt;# &lt;/span&gt;sbctl&lt;span class="w"&gt; &lt;/span&gt;status
&lt;span class="go"&gt;Installed:      ✘ Sbctl is not installed&lt;/span&gt;
&lt;span class="go"&gt;Setup Mode:     ✘ Enabled&lt;/span&gt;
&lt;span class="go"&gt;Secure Boot     ✘ Disabled&lt;/span&gt;
&lt;span class="go"&gt;Vendor Keys:    microsoft&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;The important info is that Secure Boot is disabled and in Setup Mode.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;NOTE&lt;/strong&gt;: Removing the Microsoft vendor keys may render some devices unusable.  Before you proceed, read the &lt;a href="https://github.com/Foxboron/sbctl/wiki/FAQ#option-rom"&gt;sbctl notes on Option ROMs&lt;/a&gt; to learn how to verify whether they can be safely removed.&lt;/p&gt;
&lt;p&gt;If you have verified that you can indeed remove the Microsoft keys, generate your Secure Boot keys using &lt;code&gt;sbctl create-keys&lt;/code&gt; and enroll them with &lt;code&gt;sbctl enroll-keys&lt;/code&gt;.
If you want to keep the Microsoft vendor keys around, enroll them as well with &lt;code&gt;sbctl enroll-keys -m&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Another run of &lt;code&gt;sbctl status&lt;/code&gt; should now read:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="gp"&gt;# &lt;/span&gt;sbctl&lt;span class="w"&gt; &lt;/span&gt;status
&lt;span class="go"&gt;Installed:      ✓ Sbctl is installed&lt;/span&gt;
&lt;span class="go"&gt;Owner GUID:     46af18e7-46b9-48bb-8b3c-a85249a72cbd&lt;/span&gt;
&lt;span class="go"&gt;Setup Mode:     ✓ Disabled&lt;/span&gt;
&lt;span class="go"&gt;Secure Boot     ✘ Disabled&lt;/span&gt;
&lt;span class="go"&gt;Vendor Keys:    &lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;To actually sign the UKI's, run &lt;code&gt;mkinitcpio -p linux&lt;/code&gt; again.  This time you should see the following new lines in the output:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="gp"&gt;# &lt;/span&gt;mkinitcpio&lt;span class="w"&gt; &lt;/span&gt;-p&lt;span class="w"&gt; &lt;/span&gt;linux
&lt;span class="go"&gt;...&lt;/span&gt;
&lt;span class="go"&gt;==&amp;gt; Running post hooks&lt;/span&gt;
&lt;span class="go"&gt;  -&amp;gt; Running post hook: [sbctl]&lt;/span&gt;
&lt;span class="go"&gt;Signing /efi/EFI/arch/arch.efi&lt;/span&gt;
&lt;span class="go"&gt;✓ Signed /efi/EFI/arch/arch.efi&lt;/span&gt;
&lt;span class="go"&gt;==&amp;gt; Post processing done&lt;/span&gt;
&lt;span class="go"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;You can also verify whether the files in the ESP are signed using &lt;code&gt;sbctl verify&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="gp"&gt;# &lt;/span&gt;sbctl&lt;span class="w"&gt; &lt;/span&gt;verify
&lt;span class="go"&gt;Verifying file database and EFI images in /efi...&lt;/span&gt;
&lt;span class="go"&gt;✓ /efi/EFI/arch/arch.efi is signed&lt;/span&gt;
&lt;span class="go"&gt;✓ /efi/EFI/arch/arch-fallback.efi is signed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Now, reboot into firmware setup again and enable Secure Boot.  Again, you can verify the Secure Boot status with &lt;code&gt;sbctl status&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="gp"&gt;# &lt;/span&gt;sbctl&lt;span class="w"&gt; &lt;/span&gt;status
&lt;span class="go"&gt;Installed:      ✓ Sbctl is installed&lt;/span&gt;
&lt;span class="go"&gt;Owner GUID:     46af18e7-46b9-48bb-8b3c-a85249a72cbd&lt;/span&gt;
&lt;span class="go"&gt;Setup Mode:     ✓ Disabled&lt;/span&gt;
&lt;span class="go"&gt;Secure Boot     ✓ Enabled&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;From now on, only images signed by your key should be bootable unless you disable Secure Boot again.&lt;/p&gt;
&lt;h2 id="firmware-updates"&gt;Firmware Updates&lt;/h2&gt;
&lt;p&gt;If you're using &lt;code&gt;fwupd&lt;/code&gt; to update your firmware, you should also sign the &lt;code&gt;fwupdx64.efi&lt;/code&gt; image.  You only need to do this once, afterwards this is taken care of by a pacman hook of sbctl:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="gp"&gt;# &lt;/span&gt;sbctl&lt;span class="w"&gt; &lt;/span&gt;sign&lt;span class="w"&gt; &lt;/span&gt;-s&lt;span class="w"&gt; &lt;/span&gt;-o&lt;span class="w"&gt; &lt;/span&gt;/usr/lib/fwupd/efi/fwupdx64.efi.signed&lt;span class="w"&gt; &lt;/span&gt;/usr/lib/fwupd/efi/fwupdx64.efi
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;You also need to tell fwupd to not use the Secure Boot Shim signed by Microsoft.  For this, you need to place the following lines in &lt;code&gt;/etc/fwupd/fwupd.conf&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="k"&gt;[uefi_capsule]&lt;/span&gt;
&lt;span class="na"&gt;DisableShimForSecureBoot&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;h2 id="efi-shell"&gt;EFI Shell&lt;/h2&gt;
&lt;p&gt;In order to be able to troubleshoot boot issues without e.g. a GRUB2 shell available and without resorting to a live system, I also placed an EFI shell in the ESP:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="gp"&gt;# &lt;/span&gt;pacman&lt;span class="w"&gt; &lt;/span&gt;-S&lt;span class="w"&gt; &lt;/span&gt;edk2-shell
&lt;span class="gp"&gt;# &lt;/span&gt;cp&lt;span class="w"&gt; &lt;/span&gt;/usr/share/edk2-shell/x64/Shell_Full.efi&lt;span class="w"&gt; &lt;/span&gt;/efi/EFI/arch/shell.efi
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;I did however &lt;strong&gt;NOT&lt;/strong&gt; sign the EFI shell binary, so that it can't simply be started by anyone, compromising Secure Boot - instead I need to temporarily disable Secure Boot if I ever need to use it.&lt;/p&gt;</content><category term="hacks"/><category term="Linux"/><category term="Secure Boot"/><category term="Boot"/><category term="Automation"/></entry><entry><title>Migration of Etherpad Lite from MariaDB to PostgreSQL</title><link href="https://s3lph.me/migration-of-etherpad-lite-from-mariadb-to-postgresql.html" rel="alternate"/><published>2025-08-30T00:00:00+02:00</published><updated>2025-08-30T00:00:00+02:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2025-08-30:/migration-of-etherpad-lite-from-mariadb-to-postgresql.html</id><summary type="html">&lt;p&gt;I recently migrated an installation of &lt;a href="https://docs.etherpad.org/"&gt;Etherpad Lite&lt;/a&gt; from
an old setup based on a git checkout to a newer container-based setup.
The old setup used MariaDB as its backing database.&lt;/p&gt;
&lt;p&gt;In the new container setup, I wanted to expose the database's UNIX
domain socket to the container.  Unfortunately, the …&lt;/p&gt;</summary><content type="html">&lt;p&gt;I recently migrated an installation of &lt;a href="https://docs.etherpad.org/"&gt;Etherpad Lite&lt;/a&gt; from
an old setup based on a git checkout to a newer container-based setup.
The old setup used MariaDB as its backing database.&lt;/p&gt;
&lt;p&gt;In the new container setup, I wanted to expose the database's UNIX
domain socket to the container.  Unfortunately, the environment
variables used to configure the default Etherpad Lite container image
don't permit configuring a MariaDB backend using a UNIX socket.
However, such as setup was possible when using a PostgreSQL backend.&lt;/p&gt;
&lt;p&gt;Therefore, I wanted to figure out whether the database backend could
easily be switched, and the data migrated.&lt;/p&gt;
&lt;h2 id="built-in-migration-command"&gt;Built-in Migration Command&lt;/h2&gt;
&lt;p&gt;Now, Etherpad Lite comes with a handy &lt;a href="https://docs.etherpad.org/cli.html"&gt;migrateDB&lt;/a&gt; command
line tool that promises to do exactly what I needed: Provide an old
and a new database configuration, and migrate data from another:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;pnpm&lt;span class="w"&gt; &lt;/span&gt;run&lt;span class="w"&gt; &lt;/span&gt;--filter&lt;span class="w"&gt; &lt;/span&gt;bin&lt;span class="w"&gt; &lt;/span&gt;migrateDB&lt;span class="w"&gt; &lt;/span&gt;--file1&lt;span class="w"&gt; &lt;/span&gt;old.json&lt;span class="w"&gt; &lt;/span&gt;--file2&lt;span class="w"&gt; &lt;/span&gt;new.json
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;The documentation of the command states:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;After some time the data should be copied over to the new database.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;However, after some time had passed (I think around 15 minutes), and
the command still hadn't finished, I compared the number of records in
the old MariaDB database and the new PostgreSQL database:&lt;/p&gt;
&lt;p&gt;The old database contained around 6.5 million records.  After these 15
minutes, only around 8500 of these records had been migrated to the
new database.  Extrapolating from this, the entire migration would
have taken around 8 days.  8 days during which the Etherpad Lite
instance would have to have remained offline, because any changes
would not have been migrated.&lt;/p&gt;
&lt;p&gt;So this unfortunately was not an option.&lt;/p&gt;
&lt;h2 id="pgloader"&gt;pgloader&lt;/h2&gt;
&lt;p&gt;The next approach I tried was using &lt;a href="https://pgloader.io/"&gt;pgloader&lt;/a&gt;, a "load
anything into PostgreSQL" tool.  It appeared to be similarly
straight-forward as the &lt;code&gt;migrateDB&lt;/code&gt; command: Provide it with a source
and a destination, and then let the magic happen.  So my first try
looked like this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;pgloader&lt;span class="w"&gt; &lt;/span&gt;mysql://etherpad:password@mysql/etherpad&lt;span class="w"&gt; &lt;/span&gt;postgresql:///etherpad
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;And, indeed, magic happened: After less than 4 minutes, the migration
was completed!&lt;/p&gt;
&lt;p&gt;Well, sort of... The data was imported into the schema &lt;code&gt;etherpad&lt;/code&gt; in
the database &lt;code&gt;etherpad&lt;/code&gt;.  However, it appeared that Etherpad Lite
expected the schema to have the default name, &lt;code&gt;public&lt;/code&gt;.
Unfortunately, simply renaming the schema after the fact caused some
permission issues which I wasn't able to figure out on the spot.&lt;/p&gt;
&lt;p&gt;But a schema rename command can also be added to the pgloader
invocation.  To do this, the migration command has to be provided as a
config file, rather than on the command line:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="k"&gt;LOAD&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DATABASE&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;mysql&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;//&lt;/span&gt;&lt;span class="n"&gt;etherpad&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;mysql&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;etherpad&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="k"&gt;INTO&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;postgresql&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;///&lt;/span&gt;&lt;span class="n"&gt;etherpad&lt;/span&gt;
&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SCHEMA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;etherpad&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;RENAME&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TO&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;public&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;After dropping the erroneous schema, I re-ran pgloader, and this time
Etherpad Lite started up with its PostgreSQL backend without any
issues:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="go"&gt;~$ pgloader my.load&lt;/span&gt;
&lt;span class="go"&gt;2025-08-17T14:51:47.024000Z LOG pgloader version &amp;quot;3.6.10~devel&amp;quot;&lt;/span&gt;
&lt;span class="go"&gt;2025-08-17T14:51:47.256001Z LOG Migrating from #&amp;lt;MYSQL-CONNECTION mysql://etherpad@mysql:3306/etherpad {1005C5CE93}&amp;gt;&lt;/span&gt;
&lt;span class="go"&gt;2025-08-17T14:51:47.256001Z LOG Migrating into #&amp;lt;PGSQL-CONNECTION pgsql://etherpad@UNIX:5432/etherpad {1005C5CF13}&amp;gt;&lt;/span&gt;
&lt;span class="go"&gt;2025-08-17T14:54:23.139960Z LOG report summary reset&lt;/span&gt;
&lt;span class="go"&gt;             table name     errors       rows      bytes      total time&lt;/span&gt;
&lt;span class="go"&gt;-----------------------  ---------  ---------  ---------  --------------&lt;/span&gt;
&lt;span class="go"&gt;        fetch meta data          0          2                     0.160s&lt;/span&gt;
&lt;span class="go"&gt;         Create Schemas          0          0                     0.000s&lt;/span&gt;
&lt;span class="go"&gt;       Create SQL Types          0          0                     0.016s&lt;/span&gt;
&lt;span class="go"&gt;          Create tables          0          2                     0.052s&lt;/span&gt;
&lt;span class="go"&gt;         Set Table OIDs          0          1                     0.020s&lt;/span&gt;
&lt;span class="go"&gt;-----------------------  ---------  ---------  ---------  --------------&lt;/span&gt;
&lt;span class="go"&gt;           public.store          0    6483656   920.8 MB       1m32.360s&lt;/span&gt;
&lt;span class="go"&gt;-----------------------  ---------  ---------  ---------  --------------&lt;/span&gt;
&lt;span class="go"&gt;COPY Threads Completion          0          4                  1m32.368s&lt;/span&gt;
&lt;span class="go"&gt; Index Build Completion          0          1                   1m2.708s&lt;/span&gt;
&lt;span class="go"&gt;         Create Indexes          0          1                   1m2.616s&lt;/span&gt;
&lt;span class="go"&gt;        Reset Sequences          0          0                     0.152s&lt;/span&gt;
&lt;span class="go"&gt;           Primary Keys          0          1                     0.004s&lt;/span&gt;
&lt;span class="go"&gt;    Create Foreign Keys          0          0                     0.000s&lt;/span&gt;
&lt;span class="go"&gt;        Create Triggers          0          0                     0.004s&lt;/span&gt;
&lt;span class="go"&gt;       Install Comments          0          0                     0.000s&lt;/span&gt;
&lt;span class="go"&gt;-----------------------  ---------  ---------  ---------  --------------&lt;/span&gt;
&lt;span class="go"&gt;      Total import time          ✓    6483656   920.8 MB       3m37.852s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;So, if you're planning to move a workload to a PostgreSQL backend,
pgloader is definitely worth looking into.&lt;/p&gt;</content><category term="hacks"/><category term="Migration"/><category term="Etherpad"/><category term="Database"/><category term="PostgreSQL"/></entry><entry><title>Libvirt to Proxmox VE Migration</title><link href="https://s3lph.me/libvirt-to-proxmox-ve-migration.html" rel="alternate"/><published>2025-06-20T00:00:00+02:00</published><updated>2025-06-20T00:00:00+02:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2025-06-20:/libvirt-to-proxmox-ve-migration.html</id><summary type="html">&lt;p&gt;In our hackerspace, we were running a single-node hypervisor for some
virtual machines on hardware that was roughly 15 years old.  As this
system was not only old, but also loud and power-hungry, we wanted to
replace it with a more modern one that we had recently obtained.&lt;/p&gt;
&lt;p&gt;We also …&lt;/p&gt;</summary><content type="html">&lt;p&gt;In our hackerspace, we were running a single-node hypervisor for some
virtual machines on hardware that was roughly 15 years old.  As this
system was not only old, but also loud and power-hungry, we wanted to
replace it with a more modern one that we had recently obtained.&lt;/p&gt;
&lt;p&gt;We also wanted to replace the hypervisor solution: The old system was
using &lt;a href="https://libvirt.org/"&gt;libvirt&lt;/a&gt; with the VM disks on LVM volumes.  For the
new system, we wanted to switch to &lt;a href="https://pve.proxmox.com/wiki/Main_Page"&gt;Proxmox VE&lt;/a&gt; and its builtin
ZFS storage.&lt;/p&gt;
&lt;p&gt;However, this change in storage format also meant that we couldn't
simply copy the VM disks with &lt;code&gt;dd&lt;/code&gt; or similar, as a conversion by
Proxmox was required.  Luckily, this &lt;a href="https://pve.proxmox.com/wiki/Storage_Migration"&gt;storage migration&lt;/a&gt;
is only a matter of a few clicks in the Proxmox web interface.
Furthermore, it can be done live, i.e. while a VM is running.&lt;/p&gt;
&lt;p&gt;Therefore we came up with the following approach for migrating all VMs
with as little downtime as possible.&lt;/p&gt;
&lt;h2 id="storage-migration-approach"&gt;Storage Migration Approach&lt;/h2&gt;
&lt;p&gt;The general idea was to set up the old hypervisor as an &lt;a href="https://en.wikipedia.org/wiki/ISCSI#Target"&gt;iSCSI
target&lt;/a&gt; that exposes the LVM volumes as &lt;abbr title="Logical
Unit Number"&gt;LUNs&lt;/abbr&gt;, and set up Proxmox to use this iSCSI target
for storage.&lt;/p&gt;
&lt;p&gt;We would then create new VMs on Proxmox that resembled the old libvirt
VMs as closely as &lt;s&gt;possible&lt;/s&gt; necessary, and initially set up
these VMs to boot from the already existing iSCSI volumes.  As a final
step, we would then perform a live storage migration to the ZFS
storage on the new hypervisor.&lt;/p&gt;
&lt;p&gt;By following this approach, the downtime for each VM should only be
the time span between being shut down on the old host and being started
on the new host.&lt;/p&gt;
&lt;h2 id="iscsi-target-initiator-setup"&gt;iSCSI Target &amp;amp; Initiator Setup&lt;/h2&gt;
&lt;p&gt;To achieve the necessary data throughput, we added a direct network
connection between the old and new server consisting of two 10 Gbps
links in a LACP bond configuration with static IP
configuration and enabled Ethernet Jumbo frames.&lt;/p&gt;
&lt;p&gt;The relevant network config for &lt;code&gt;/etc/network/interfaces&lt;/code&gt; on the new server looked like this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="c1"&gt;# The physical SFP+ interfaces&lt;/span&gt;
iface&lt;span class="w"&gt; &lt;/span&gt;eno49&lt;span class="w"&gt; &lt;/span&gt;inet&lt;span class="w"&gt; &lt;/span&gt;manual
iface&lt;span class="w"&gt; &lt;/span&gt;eno50&lt;span class="w"&gt; &lt;/span&gt;inet&lt;span class="w"&gt; &lt;/span&gt;manual

&lt;span class="c1"&gt;# The bond interface with static IP configuration&lt;/span&gt;
auto&lt;span class="w"&gt; &lt;/span&gt;bond1
iface&lt;span class="w"&gt; &lt;/span&gt;bond1&lt;span class="w"&gt; &lt;/span&gt;inet6&lt;span class="w"&gt; &lt;/span&gt;static
&lt;span class="w"&gt;  &lt;/span&gt;address&lt;span class="w"&gt; &lt;/span&gt;fd00::1/64
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="c1"&gt;# LACP bond configuration&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;bond-slaves&lt;span class="w"&gt; &lt;/span&gt;eno1&lt;span class="w"&gt; &lt;/span&gt;eno2
&lt;span class="w"&gt;  &lt;/span&gt;bond-miimon&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;bond-mode&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;802&lt;/span&gt;.3ad
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="c1"&gt;# Jumbo frames&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;mtu&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;9000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;The other side looks pretty much identical apart from the IP address (there we chose &lt;code&gt;fd00::2&lt;/code&gt;) and the physical interface names.&lt;/p&gt;
&lt;p&gt;We then proceeded to set up the iSCSI target on the old server by installing &lt;code&gt;tgtd&lt;/code&gt; and setting up the target and LUNs:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="c1"&gt;# Install and start tgtd&lt;/span&gt;
apt&lt;span class="w"&gt; &lt;/span&gt;install&lt;span class="w"&gt; &lt;/span&gt;tgt
systemctl&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;enable&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;--now&lt;span class="w"&gt; &lt;/span&gt;tgt.service
&lt;span class="c1"&gt;# Create the iSCSI target&lt;/span&gt;
tgtadm&lt;span class="w"&gt; &lt;/span&gt;--lld&lt;span class="w"&gt; &lt;/span&gt;iscsi&lt;span class="w"&gt; &lt;/span&gt;--mode&lt;span class="w"&gt; &lt;/span&gt;target&lt;span class="w"&gt; &lt;/span&gt;--op&lt;span class="w"&gt; &lt;/span&gt;new&lt;span class="w"&gt; &lt;/span&gt;--tid&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-T&lt;span class="w"&gt; &lt;/span&gt;iqn.2025-06.old:tgt1
&lt;span class="c1"&gt;# Permit access for the Proxmox hypervisor&lt;/span&gt;
tgtadm&lt;span class="w"&gt; &lt;/span&gt;--lld&lt;span class="w"&gt; &lt;/span&gt;iscsi&lt;span class="w"&gt; &lt;/span&gt;--mode&lt;span class="w"&gt; &lt;/span&gt;target&lt;span class="w"&gt; &lt;/span&gt;--op&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;bind&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;--tid&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;--initiator-address&lt;span class="w"&gt; &lt;/span&gt;fd00::1
&lt;span class="c1"&gt;# Create LUN 1 from vm01&amp;#39;s image&lt;/span&gt;
tgtadm&lt;span class="w"&gt; &lt;/span&gt;--lld&lt;span class="w"&gt; &lt;/span&gt;iscsi&lt;span class="w"&gt; &lt;/span&gt;--mode&lt;span class="w"&gt; &lt;/span&gt;logicalunit&lt;span class="w"&gt; &lt;/span&gt;--op&lt;span class="w"&gt; &lt;/span&gt;new&lt;span class="w"&gt; &lt;/span&gt;--tid&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;--lun&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;--backing-store&lt;span class="w"&gt; &lt;/span&gt;/dev/vg_old/vm01
&lt;span class="c1"&gt;# ... repeat for additional LUNs&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Note that this configuration is not persisted to disk; if you want to
set up persistent tgtd config, check out &lt;a href="https://manpages.debian.org/stable/tgt/targets.conf.5.en.html"&gt;&lt;code&gt;man 5 targets.conf&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Setup of the iSCSI initiator is even simpler; it's just a few clicks
in Proxmox' web interface:&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2025-06-20-proxmox-iscsi-storage.png"&gt;
    &lt;img src="https://s3lph.me/images/2025-06-20-proxmox-iscsi-storage.png"
         title="Adding iSCSI storage to Proxmox"
         alt="Adding iSCSI storage to Proxmox"&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure1" class="figureid"&gt;Figure 1:&lt;/a&gt;
    Adding iSCSI storage to Proxmox
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;ol&gt;
&lt;li&gt;Navigate to the PVE datacenter's storage settings&lt;/li&gt;
&lt;li&gt;Add a new storage and choose &lt;code&gt;iSCSI&lt;/code&gt;.  If this option is not available, you may need to install the &lt;code&gt;iscsi-tools&lt;/code&gt; package.&lt;/li&gt;
&lt;li&gt;Choose a name (ID) for the storage and enter the IP address of the iSCSI target for the portal.  The target one further down should auto-complete to the IQN you set up before.&lt;/li&gt;
&lt;li&gt;Make sure both "Enabled" and "Use LUNs directly" are checked.&lt;/li&gt;
&lt;li&gt;Click "Add" to complete adding the storage.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;After this is completed, you should be able to see the iSCSI LUNs as
VM images in the Proxmox node's iSCSI storage, or as disks by running
e.g. &lt;code&gt;lsblk&lt;/code&gt; on the Proxmox system.&lt;/p&gt;
&lt;h3 id="side-note-prevent-lvs-from-being-activated"&gt;Side Note: Prevent LVs from Being Activated&lt;/h3&gt;
&lt;p&gt;If you're using an entire disk image as LVM physical volume in a VM
(rather than have a partition table on it and use one of its
partitions as PV), Proxmox will refuse to use this disk - it won't
even show up in the list of LUNs.&lt;/p&gt;
&lt;p&gt;To prevent this, you can configure the new server to ignore such a LVM
device.  To do so, edit &lt;code&gt;/etc/lvm/lvm.conf&lt;/code&gt;, navigate to the very
bottom, and add an entry to the already existing &lt;code&gt;global_filter&lt;/code&gt; line:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;devices {
     # added by pve-manager to avoid scanning ZFS zvols and Ceph rbds
     global_filter=[&amp;quot;r|/dev/zd.*|&amp;quot;,&amp;quot;r|/dev/rbd.*|&amp;quot;,&amp;quot;r|/dev/theVGtoIgnore.*|&amp;quot;]
}
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;You may need to run &lt;code&gt;systemctl restart lvm2-monitor&lt;/code&gt; after this change.&lt;/p&gt;
&lt;h2 id="vm-migration"&gt;VM Migration&lt;/h2&gt;
&lt;p&gt;First, we gathered all the relevant information from the VM's libvirt config:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Number of network interfaces, their MAC addresses and VLAN ID they are attached to.&lt;/li&gt;
&lt;li&gt;Number of CPU cores and amount of RAM.&lt;/li&gt;
&lt;li&gt;Number of disks and their backing LV.&lt;/li&gt;
&lt;li&gt;Any additional hardware attached to the VM that might need to be migrated.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We then used this information to create a matching VM in Proxmox.
However, we did not boot the new VM yet!  First we needed to make sure
the VM's network would come up on the new host.&lt;/p&gt;
&lt;p&gt;As the VMs' hardware layout on Proxmox is different from that on
libvirt, the network interfaces inside the VM will be named
differently if that VM is running a modern Linux system.  Luckily,
these are rather predictable; on VMs created with our Proxmox VE
version, 8.4.1, without changing the default hardware layout, the
first network interface would always be named &lt;code&gt;ens18&lt;/code&gt;, followed by
&lt;code&gt;ens19&lt;/code&gt; and so on for any additional interfaces.&lt;/p&gt;
&lt;p&gt;This allowed us to change the VM's network config while it was still
running on the old libvirt system, along with any other config that
relied on the interface name, such as firewall rules.&lt;/p&gt;
&lt;p&gt;At this time, we also added additional hardware to the VMs, such as a
serial console, or passing through USB devices from the host.&lt;/p&gt;
&lt;p&gt;Now it was finally time to shut down the VM on the old libvirt
hypervisor, &lt;strong&gt;disable start on boot&lt;/strong&gt;, and click "Start VM" in
Proxmox.  By doing this in rapid succession, the VM's downtime was not
much longer than a regular reboot.&lt;/p&gt;
&lt;p&gt;We then migrated the storage via Proxmox' live storage migration feature:&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2025-06-20-proxmox-move-disk.png"&gt;
    &lt;img src="https://s3lph.me/images/2025-06-20-proxmox-move-disk.png"
         title="Live migration of a VM disk in Proxmox"
         alt="Live migration of a VM disk in Proxmox"&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure2" class="figureid"&gt;Figure 2:&lt;/a&gt;
    Live migration of a VM disk in Proxmox
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;ol&gt;
&lt;li&gt;Navigate to the VM's "Hardware" tab.&lt;/li&gt;
&lt;li&gt;Select the disk you want to migrate.&lt;/li&gt;
&lt;li&gt;Under "Disk Action", choose "Move Storage".&lt;/li&gt;
&lt;li&gt;Choose the target storage (&lt;code&gt;local-zfs&lt;/code&gt; in this case).&lt;/li&gt;
&lt;li&gt;Click "move disk".&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;After the migration was completed, we were left with an additional
"Unused disk" entry in the hardware list.  The final step was to
remove these iSCSI disks.&lt;/p&gt;
&lt;p&gt;Once all VMs are migrated, we made sure to remove the iSCSI storage from
Proxmox, and sign the Proxmox host out of the iSCSI target
(unfortunately, Proxmox does not do this automatically when removing
the iSCSI storage):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;iscsiadm&lt;span class="w"&gt; &lt;/span&gt;-m&lt;span class="w"&gt; &lt;/span&gt;node&lt;span class="w"&gt; &lt;/span&gt;-T&lt;span class="w"&gt; &lt;/span&gt;iqn.2025-06.oldhost:tgt1&lt;span class="w"&gt; &lt;/span&gt;--portal&lt;span class="w"&gt; &lt;/span&gt;fd00::2&lt;span class="w"&gt; &lt;/span&gt;-u
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;h2 id="preventing-user-errors"&gt;Preventing User Errors&lt;/h2&gt;
&lt;p&gt;We initially exposed all VMs' disks via iSCSI simultaneously, but
quickly realized that - since they only appear as target and LUN
number on the Proxmox side - there is a notable risk for accidentally
assigning disks to the wrong VMs.&lt;/p&gt;
&lt;p&gt;Therefore, we instead only exposed one VM's disks at a time, and after
completing each migration performed the following steps to remove the
no longer needed LUNs:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Remove the iSCSI storage in Proxmox' web interface.&lt;/li&gt;
&lt;li&gt;Sign the iSCSI initator out from the target: &lt;code&gt;iscsiadm -m node -T iqn.2025-06.oldhost:tgt1 --portal fd00::2 -u&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Remove the LUN on the target: &lt;code&gt;tgtadm --lld iscsi --mode logicalunit --op delete --tid 1 --lun 1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Set up the iSCSI storage in Proxmox' web interface again.&lt;/li&gt;
&lt;/ol&gt;</content><category term="hacks"/><category term="Migration"/><category term="Virtualization"/><category term="Proxmox VE"/></entry><entry><title>Talk: «IPv6-only in the Home Network»</title><link href="https://s3lph.me/vortrag-ipv6-only-im-heimnetz.html" rel="alternate"/><published>2025-05-30T00:00:00+02:00</published><updated>2025-05-30T00:00:00+02:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2025-05-30:/vortrag-ipv6-only-im-heimnetz.html</id><content type="html">&lt;p&gt;I held a talk at CoSin 2025 titled «IPv6-only in the Home Network».&lt;/p&gt;
&lt;p&gt;The slides (in German) are available in the conference archive: &lt;a href="https://fahrplan.cosin.ch/fahrplan/2025/events/1ff89037-2ba8-5c13-9da4-e2e0c74ea5e2/attachments/resource_0.pdf"&gt;Slides&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Here's the recording of the talk (also in German):&lt;/p&gt;</content><category term="talks"/><category term="Talks"/><category term="Vorträge"/><category term="IPv6"/></entry><entry><title>Vortrag: «IPv6-only im Heimnetz»</title><link href="https://s3lph.me/vortrag-ipv6-only-im-heimnetz-de.html" rel="alternate"/><published>2025-05-30T00:00:00+02:00</published><updated>2025-05-30T00:00:00+02:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2025-05-30:/vortrag-ipv6-only-im-heimnetz-de.html</id><content type="html">&lt;p&gt;Ich habe auf der CoSin 2025 einen Vortrag mit dem Titel «IPv6-only im Heimnetz» gehalten.&lt;/p&gt;
&lt;p&gt;Die Folien sind im Konferenzarchiv verfügbar: &lt;a href="https://fahrplan.cosin.ch/fahrplan/2025/events/1ff89037-2ba8-5c13-9da4-e2e0c74ea5e2/attachments/resource_0.pdf"&gt;Slides&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Hier ist die Aufzeichnung des Vortrags:&lt;/p&gt;</content><category term="talks"/><category term="Talks"/><category term="Vorträge"/><category term="IPv6"/></entry><entry><title>Manually Replacing Matrix Rooms</title><link href="https://s3lph.me/manually-replacing-matrix-rooms.html" rel="alternate"/><published>2025-04-26T00:00:00+02:00</published><updated>2025-04-26T00:00:00+02:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2025-04-26:/manually-replacing-matrix-rooms.html</id><summary type="html">&lt;p&gt;In our hackerspace community, we recently ran into some issues with
end-to-end encrypted Matrix rooms: In a previously unencrypted public
room with around 100 users across lots of different home servers, a
room admin had enabled end-to-end encryption by accident.&lt;/p&gt;
&lt;p&gt;Unfortunately, end-to-end encryption in Matrix can still be unreliable
at …&lt;/p&gt;</summary><content type="html">&lt;p&gt;In our hackerspace community, we recently ran into some issues with
end-to-end encrypted Matrix rooms: In a previously unencrypted public
room with around 100 users across lots of different home servers, a
room admin had enabled end-to-end encryption by accident.&lt;/p&gt;
&lt;p&gt;Unfortunately, end-to-end encryption in Matrix can still be unreliable
at times, and  encryption (or rather decryption) errors  can be caused
by a &lt;a href="https://blog.neko.dev/posts/unable-to-decrypt-matrix.html"&gt;wide range of issues&lt;/a&gt; by
&lt;a href="https://joinmatrix.org/guide/fix-decryption-error/"&gt;both sender and recipient&lt;/a&gt;.  Therefore we decided that we
wanted to return to an unencrypted room.&lt;/p&gt;
&lt;p&gt;Please note that this blog post should &lt;strong&gt;not&lt;/strong&gt; be interpreted as
advocacy against end-to-end encryption; in fact, I'm a huge fan of
end-to-end encryption.  However, this specific case is about a room that
is public anyway, and where E2EE was actively leading to issues.&lt;/p&gt;
&lt;h2 id="how-to-disable-end-to-end-encryption-in-matrix"&gt;How to Disable End-to-end Encryption in Matrix?&lt;/h2&gt;
&lt;p&gt;The short answer is: You don't!&lt;/p&gt;
&lt;p&gt;Once E2EE has been enabled in a Matrix room, there is no way to
disable it.  The only choice is to create an entirely new and
unencrypted room and tell your users to join that room instead.&lt;/p&gt;
&lt;p&gt;The good news is that Matrix makes it fairly easy for your users to
join the new room; if done properly, it's only a single click or tap
in most clients.&lt;/p&gt;
&lt;p&gt;If you're asking yourself "Matrix has a simple room upgrade API, why
can't you just use that", the answer is simple: The room upgrade API
causes the server to replicate all room state events to the new room.
This includes the "enable encryption" event as well, and you'd be left
with a new, but encrypted room.&lt;/p&gt;
&lt;h2 id="how-to-replace-a-room"&gt;How to Replace a Room?&lt;/h2&gt;
&lt;p&gt;The first thing you'll need is a user with admin permissions (power
level 100) in the existing room, and an access token for that user.
An access token can usually be obtained quite easily, e.g. in Element
you can export the access token under &lt;code&gt;Settings&lt;/code&gt; / &lt;code&gt;Advanced
settings&lt;/code&gt; / &lt;code&gt;Dev Tools&lt;/code&gt; / &lt;code&gt;Access Token&lt;/code&gt; when Developer mode is
enabled.  An access token issued by Synapse looks like the following,
other homeservers may issue them in a different format:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;syt_QcG6Rup_QEBF9Wji2mQzbyXoSgTw_9LJhae
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;The next thing we'll need is the internal room ID of your Matrix room.
If your client does not reveal this, you can query it from the
client-server API of your homeserver.  This lookup does not yet
require authentication:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="gp"&gt;$ &lt;/span&gt;curl&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;https://matrix.example.org/_matrix/client/v3/directory/room/%23awesome-room:example.org&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;jq
&lt;span class="go"&gt;{&lt;/span&gt;
&lt;span class="hll"&gt;&lt;span class="go"&gt;  &amp;quot;room_id&amp;quot; &amp;quot;!udc6osfy94WuSou9QG:example.org&amp;quot;,&lt;/span&gt;
&lt;/span&gt;&lt;span class="go"&gt;  &amp;quot;servers&amp;quot;: [&lt;/span&gt;
&lt;span class="go"&gt;    &amp;quot;example.org&amp;quot;,&lt;/span&gt;
&lt;span class="go"&gt;    &amp;quot;...&amp;quot;&lt;/span&gt;
&lt;span class="go"&gt;  ]&lt;/span&gt;
&lt;span class="go"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;(&lt;a href="https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3directoryroomroomalias"&gt;API specification&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;Note that you have to URL-encode the &lt;code&gt;#&lt;/code&gt; symbol in the room alias as
&lt;code&gt;%23&lt;/code&gt; to prevent it from being treated as the URL fragment separator.&lt;/p&gt;
&lt;p&gt;With these two pieces of data we can now retrieve the latest event in
the current room.  We need this event (or rather, its event ID) to
link the room we will create in the next step back to the "end" of the
old room to allow users to jump between the rooms as seamlessly as
possible:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="go"&gt;curl -H &amp;#39;Authorization: Bearer syt_QcG6Rup_QEBF9Wji2mQzbyXoSgTw_9LJhae&amp;#39; &amp;#39;https://matrix.example.org/_matrix/client/v3/rooms/!udc6osfy94WuSou9QG:example.org/messages?limit=1&amp;amp;dir=b&amp;#39; | jq&lt;/span&gt;
&lt;span class="go"&gt;{&lt;/span&gt;
&lt;span class="go"&gt;  &amp;quot;chunk&amp;quot;: [&lt;/span&gt;
&lt;span class="go"&gt;    {&lt;/span&gt;
&lt;span class="go"&gt;      &amp;quot;type&amp;quot;: &amp;quot;...&amp;quot;,&lt;/span&gt;
&lt;span class="go"&gt;      &amp;quot;content&amp;quot;: {...},&lt;/span&gt;
&lt;span class="go"&gt;      &amp;quot;room_id&amp;quot;: &amp;quot;!udc6osfy94WuSou9QG:example.org&amp;quot;,&lt;/span&gt;
&lt;span class="go"&gt;      &amp;quot;sender&amp;quot;: &amp;quot;@otheruser:matrix.org&amp;quot;,&lt;/span&gt;
&lt;span class="go"&gt;      &amp;quot;user_id&amp;quot;: &amp;quot;@otheruser:matrix.org&amp;quot;,&lt;/span&gt;
&lt;span class="hll"&gt;&lt;span class="go"&gt;      &amp;quot;event_id&amp;quot;: &amp;quot;$F3IubwKHQRXqp3rtDQGeI9liQAkHjPf1ac/B3l3haL8&amp;quot;&lt;/span&gt;
&lt;/span&gt;&lt;span class="go"&gt;    }&lt;/span&gt;
&lt;span class="go"&gt;  ],&lt;/span&gt;
&lt;span class="go"&gt;  &amp;quot;start&amp;quot;: &amp;quot;...&amp;quot;,&lt;/span&gt;
&lt;span class="go"&gt;  &amp;quot;end&amp;quot;: &amp;quot;...&amp;quot;&lt;/span&gt;
&lt;span class="go"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;(&lt;a href="https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3roomsroomidmessages"&gt;API specification&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;The query parameters &lt;code&gt;limit=1&lt;/code&gt; and &lt;code&gt;dir=b&lt;/code&gt; make sure that only the
very latest event is returned.&lt;/p&gt;
&lt;p&gt;Note that depending on the version of the current room, the event ID
might look differently, e.g. in the first two versions of the room
specification, there was a homeserver name attached.  In the
following, use the event ID exactly as it appears in the response
above.&lt;/p&gt;
&lt;p&gt;Now we have everything to create our new room.  I have yet to see a
Matrix client that allows you to create a room with predecessor info,
so we have to do this via an API call as well:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="gp"&gt;$ &lt;/span&gt;curl&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;-X&lt;span class="w"&gt; &lt;/span&gt;POST&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;-H&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;Authorization: Bearer syt_QcG6Rup_QEBF9Wji2mQzbyXoSgTw_9LJhae&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;-H&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;Content-Type: application/json&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;-d&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;{&amp;quot;creation_content&amp;quot;:{&amp;quot;predecessor&amp;quot;:{&amp;quot;room_id&amp;quot;:&amp;quot;!udc6osfy94WuSou9QG:example.org&amp;quot;,&amp;quot;event_id&amp;quot;:&amp;quot;$F3IubwKHQRXqp3rtDQGeI9liQAkHjPf1ac/B3l3haL8&amp;quot;}}}&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;https://matrix.example.org/_matrix/client/v3/createRoom&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;jq
&lt;span class="go"&gt;{&lt;/span&gt;
&lt;span class="hll"&gt;&lt;span class="go"&gt;  &amp;quot;room_id&amp;quot;: &amp;quot;!By6MbLtNqw5RDiNi6C:example.org&amp;quot;&lt;/span&gt;
&lt;/span&gt;&lt;span class="go"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;(&lt;a href="https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3createroom"&gt;API specification&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;You should now have a new, empty, unencrypted room with only yourself
in it show up in your Matrix client(s).&lt;/p&gt;
&lt;p&gt;At this point, you should take the time to set up this new room to
match your old one, including things like:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The room name, topic and avatar&lt;/li&gt;
&lt;li&gt;Joining policies and history visibility&lt;/li&gt;
&lt;li&gt;Remove any public aliases from the old room and assign them to the new one.&lt;/li&gt;
&lt;li&gt;Change the permissions in the old room so that regular users can't send new messages.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;All of these can of course be done through the API, but all of these
features are easily available in most Matrix clients, so there is
simply no need to bother researching and discussing the associated API
calls here.&lt;/p&gt;
&lt;p&gt;Once you are done with the setup of the new room, there's one last
step left to do: Send a "tombstone" event to the old room that renders
the room read-only and instructs users to join the new room instead:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="gp"&gt;$ &lt;/span&gt;curl&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;-X&lt;span class="w"&gt; &lt;/span&gt;PUT&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;-H&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;Authorization: Bearer syt_QcG6Rup_QEBF9Wji2mQzbyXoSgTw_9LJhae&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;-H&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;Content-Type: application/json&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;-d&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;{&amp;quot;replacement_room&amp;quot;:&amp;quot;!By6MbLtNqw5RDiNi6C:example.org&amp;quot;,&amp;quot;body&amp;quot;:&amp;quot;New room without E2EE&amp;quot;}&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;https://matrix.example.org/_matrix/client/v3/rooms/!udc6osfy94WuSou9QG:example.org/state/m.room.tombstone&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;jq
&lt;span class="go"&gt;{&lt;/span&gt;
&lt;span class="go"&gt;  &amp;quot;event_id&amp;quot;: &amp;quot;$wouAIBuVELQG57bMl28dfsBDCr94vcjzuQZ9SM1LXkb&amp;quot;&lt;/span&gt;
&lt;span class="go"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;(&lt;a href="https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3roomsroomidstateeventtypestatekey"&gt;API specification&lt;/a&gt;) (&lt;a href="https://spec.matrix.org/v1.2/client-server-api/#mroomtombstone"&gt;Event specification&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;And that's it!  Your old room is now archived and points to the new
room, while the new room retains a link back to the old one so that
members can still view older messages:&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2025-04-26-matrix-old-room.png"&gt;
    &lt;img src="https://s3lph.me/images/2025-04-26-matrix-old-room.png"
         title="Visualization of the tombstone event in the old Matrix room in Element, with a link pointing the user to the new room."
         alt="Visualization of the tombstone event in the old Matrix room in Element, with a link pointing the user to the new room."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure1" class="figureid"&gt;Figure 1:&lt;/a&gt;
    Visualization of the tombstone event in the old Matrix room in Element, with a link pointing the user to the new room.
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2025-04-26-matrix-new-room.png"&gt;
    &lt;img src="https://s3lph.me/images/2025-04-26-matrix-new-room.png"
         title="Visualization of the creation event in the new Matrix room in Element, with a link pointing the user to the old room."
         alt="Visualization of the creation event in the new Matrix room in Element, with a link pointing the user to the old room."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure2" class="figureid"&gt;Figure 2:&lt;/a&gt;
    Visualization of the creation event in the new Matrix room in Element, with a link pointing the user to the old room.
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h2 id="final-thoughts"&gt;Final Thoughts&lt;/h2&gt;
&lt;p&gt;Using these few API calls, it is comparably easy to manually replace a
Matrix room with another one without going through the room upgrade
API.  However, if you attempt to follow this approach, I have two
final recommendations:&lt;/p&gt;
&lt;p&gt;First of all, like enabling room encryption, sending a tombstone event
to a room cannot be undone.  Therefore, I recommend you create a new
dedicated room for testing this migration before going for the real
thing.  Ideally, invite a few friends to the room and exchange a few
messages before sending the tombstone event.&lt;/p&gt;
&lt;p&gt;Finally, during testing this myself, I have noticed that not all
clients show the message in the tombstone event.  So ideally, before
sending the tombstone, you might want to consider sending a regular
message informing your users of the change, what they need to do, and
if everything fails, have them re-join the new room via its public
alias.&lt;/p&gt;</content><category term="hacks"/><category term="Matrix"/><category term="E2EE"/></entry><entry><title>Configuration of Honeywell Barcode Scanners</title><link href="https://s3lph.me/configuration-of-honeywell-barcode-scanners.html" rel="alternate"/><published>2024-12-02T00:00:00+01:00</published><updated>2024-12-02T00:00:00+01:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2024-12-02:/configuration-of-honeywell-barcode-scanners.html</id><summary type="html">&lt;p&gt;I had recently obtained an used USB barcode scanner: A Honeywell Hyperion 1300G.
These scanners can be configured to use one of multiple USB operation modes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;USB Keyboard mode: The scanner emulates a keyboard and "types" the digits of scanned barcodes.&lt;/li&gt;
&lt;li&gt;USB Serial mode: The scanner emulates a serial console …&lt;/li&gt;&lt;/ul&gt;</summary><content type="html">&lt;p&gt;I had recently obtained an used USB barcode scanner: A Honeywell Hyperion 1300G.
These scanners can be configured to use one of multiple USB operation modes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;USB Keyboard mode: The scanner emulates a keyboard and "types" the digits of scanned barcodes.&lt;/li&gt;
&lt;li&gt;USB Serial mode: The scanner emulates a serial console to which it writes scanned barcodes.&lt;/li&gt;
&lt;li&gt;USB HID mode: The scanner shows up as a a USB HID POS device to be used by applications.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For my use case, I needed the scanner to be in USB HID mode, however, when I obtained it, it was configured to USB Keyboard mode.
Configuration of barcode scanners is usually done in-band, meaning by scanning special configuration barcodes.
In the case of this scanner, these barcodes can be found in the &lt;a href="https://prod-edam.honeywell.com/content/dam/honeywell-edam/sps/ppr/en-us/public/products/barcode-scanners/general-purpose-handheld/1300g/documents/sps-ppr-hp1300-ug.pdf"&gt;user manual&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Unfortunately it seemed that in-band configuration had been disabled on this scanner, so that it could only be configured via USB.
(The in-band configuration is usually disabled for security reasons in scenarios where potential adversaries might attempt to inject unexpected input via barcodes.)&lt;/p&gt;
&lt;h2 id="factory-reset-usb-traffic-logging"&gt;Factory Reset &amp;amp; USB Traffic Logging&lt;/h2&gt;
&lt;p&gt;There is a Windows-only utility from Honeywell called «&lt;a href="https://sps-support.honeywell.com/s/article/Where-can-EZConfig-for-Scanning-be-downloaded"&gt;EZConfig&lt;/a&gt;» that can be used to configure, upgrade and factory-reset barcode scanners.
So I set up a Windows VM, installed EZConfig in it and forwarded the host USB device to the VM.
This way, the factory reset was only a matter of a few mouse clicks, and this is where this story could have ended.&lt;/p&gt;
&lt;p&gt;Could have.
However, since I had already set up the Windows VM and config utility, I wanted to see whether I could figure out how the configuration of these scanners actually worked.
So I fired up Wireshark, set up &lt;a href="https://wiki.wireshark.org/CaptureSetup/USB"&gt;USB capture&lt;/a&gt; and did another factory reset:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="gp"&gt;$ &lt;/span&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;modprobe&lt;span class="w"&gt; &lt;/span&gt;usbmon
&lt;span class="gp"&gt;$ &lt;/span&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;setfacl&lt;span class="w"&gt; &lt;/span&gt;-m&lt;span class="w"&gt; &lt;/span&gt;u:s3lph:r&lt;span class="w"&gt; &lt;/span&gt;/dev/usbmon*
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;With this, I was able to obtain a &lt;a href="https://git.kabelsalat.ch/s3lph/honeywell-config/raw/commit/02c3e5619f24abf0a675836c94b8db6ca4bf6f25/pcap/honeywellusbreset.pcapng"&gt;packet capture&lt;/a&gt; of the USB traffic between the EZConfig utility and the barcode scanner.&lt;/p&gt;
&lt;h2 id="usb-traffic-analysis"&gt;USB Traffic Analysis&lt;/h2&gt;
&lt;p&gt;From the user manual I knew that an in-band factory reset could be done by scanning the commands &lt;code&gt;DEFOVR&lt;/code&gt; and &lt;code&gt;DEFALT&lt;/code&gt;.
The same was documented to be applicable out-of-band configuration of barcode scanners that are connected via RS232 instead of USB.
So I hoped that the same could be assumed for USB configuration as well.
And indeed, the packet capture contained a USB control transfer packet with the &lt;code&gt;DEFALT&lt;/code&gt; command:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nl"&gt;0000&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="mh"&gt;fd&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;0a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;16&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;4d&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;0d&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;44&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;45&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;46&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="mh"&gt;41&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;4c&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;54&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;2e&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="s"&gt;...M.DEFALT.....&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;
&lt;span class="nl"&gt;0010&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="s"&gt;................&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;
&lt;span class="nl"&gt;0020&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="s"&gt;................&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;
&lt;span class="nl"&gt;0030&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="s"&gt;................&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;So, let's try to break this down:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;fd 0a 16 4d 0d&lt;/code&gt;: This appears to be a header of some sorts.
  It appears in all outgoing control transfer messages, however the second byte (&lt;code&gt;0a&lt;/code&gt;) is changing with every message.
  Upon closer inspection, it appears to indicate the length of the payload, starting at the following byte.&lt;/li&gt;
&lt;li&gt;The header is followed by the command, &lt;code&gt;DEFALT.&lt;/code&gt; in this case.  The &lt;code&gt;.&lt;/code&gt; indicates termination of the command string.&lt;/li&gt;
&lt;li&gt;The rest of the 64 bytes is filled with zeroes.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The respose to this control transfer was read from interrupt endpoint &lt;code&gt;0x83&lt;/code&gt; and looked fairly similar:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nl"&gt;0000&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="mh"&gt;02&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;08&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;5d&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;58&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;30&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;44&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;45&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;46&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="mh"&gt;41&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;4c&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;54&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;06&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;2e&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="s"&gt;..]X0DEFALT.....&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;
&lt;span class="nl"&gt;0010&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="s"&gt;................&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;
&lt;span class="nl"&gt;0020&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="s"&gt;................&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;
&lt;span class="nl"&gt;0030&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;3f&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;00&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="s"&gt;.............?..&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Here I could make a lot more sense from the headers, as the response appears to be similar to a USB HID «Scanned Data Report», which is emitted by USB HID POS barcode scanners when they scanned a barcode, and which is fairly well defined in the USB HID standard:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;02&lt;/code&gt; again appears to be some kind of header.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;08&lt;/code&gt; is the length of the barcode's payload.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;]X0&lt;/code&gt; is an &lt;a href="https://sps-support.honeywell.com/s/article/List-of-barcode-symbology-AIM-Identifiers"&gt;AIM ID&lt;/a&gt;, which indicates which type of barcode was scanned;&lt;code&gt;]X0&lt;/code&gt; corresponds to &lt;a href="https://en.wikipedia.org/wiki/Code_39"&gt;Code 39&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;DEFALT&lt;/code&gt; is the command that was executed.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;06&lt;/code&gt; (ASCII &lt;code&gt;ACK&lt;/code&gt;) is the status report for this command, in this case a successful response.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.&lt;/code&gt; again terminates the message, and the rest is filled with zeroes.&lt;/li&gt;
&lt;li&gt;There is a &lt;code&gt;?&lt;/code&gt; character at position 0x3d`.  I couldn't figure out its purpose, and it seems to be there in every single response.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A few more things I learned from analyzing the packet dump:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The entire chapter &lt;a href="file:///home/sebastian/Downloads/ADES_Honeywell_ScanBar_1300g_Manual.pdf#page=143"&gt;Serial Programming Commands&lt;/a&gt; of the manual is applicable via USB as well, especially:&lt;/li&gt;
&lt;li&gt;The documented concatenation of multiple commands with semicolons (e.g. &lt;code&gt;DEFOVR;DEFALT.&lt;/code&gt;) is supported via USB as well.&lt;/li&gt;
&lt;li&gt;The status code in the response is indicated through ASCII control characters &lt;code&gt;ACK&lt;/code&gt; (&lt;code&gt;0x06&lt;/code&gt;), &lt;code&gt;NAK&lt;/code&gt; (&lt;code&gt;0x15&lt;/code&gt;) and &lt;code&gt;ENQ&lt;/code&gt; (&lt;code&gt;0x05&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;EZConfig uses some undocumented configuration commands.
  For example, it uses the &lt;code&gt;P_INFO.&lt;/code&gt; command to retrieve detailed information such as software version numbers from the scanner.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="writing-a-configuration-utility"&gt;Writing a Configuration Utility&lt;/h2&gt;
&lt;p&gt;With the things I had learned, I decided to attempt to write my own small configuration utility for these scanners.
Since I had never worked with USB before, I approached this as a learning opportunity to gain a deeper understanding of the USB protocol.&lt;/p&gt;
&lt;p&gt;However, the main challenge turned out to not be learning USB, but to make the config tool work independently from the device's configuration.
Depending on the operation mode the barcode scanner was in, it presented itself differently to the host.&lt;/p&gt;
&lt;p&gt;Here is an example in USB HID POS mode:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;c2e&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;b07&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Metrologic&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Instruments&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1300&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Linear&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Imager&lt;/span&gt;
&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;configuration&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Default&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;interface&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;altsetting&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;interface&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;desc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;HID&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;POS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;class&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;subclass&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;protocol&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;81&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;interrupt&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;interface&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;altsetting&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;interface&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;desc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;REM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;class&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;subclass&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;protocol&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;83&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;interrupt&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Here is the same device, but in PC Keyboard mode:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;c2e&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;b01&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Metrologic&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Instruments&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1300&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Linear&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Imager&lt;/span&gt;
&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;configuration&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Default&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;interface&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;altsetting&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;interface&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;desc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;HID&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Keyboard&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Emulation&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;class&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;subclass&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;protocol&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;87&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;interrupt&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;interface&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;altsetting&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;interface&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;desc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;REM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;class&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;subclass&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;protocol&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;81&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;interrupt&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;There's two relevant differences in here:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The USB product ID changes depending on the configuration.  In HID POS mode, it is &lt;code&gt;0b07&lt;/code&gt;, in PC Keyboard mode, it is &lt;code&gt;0b01&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The USB endpoint addresses change as well; in HID POS mode, endpoint &lt;code&gt;0x81&lt;/code&gt; is the HID POS endpoint, and &lt;code&gt;0x83&lt;/code&gt; is the configuration endpoint.  In keyboard mode, endpoint &lt;code&gt;0x81&lt;/code&gt; (the same address that was the HID POS endpoint before) becomes the configuration endpoint, whereas the keyboard endpoint is &lt;code&gt;0x87&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I solved the first issue by hard-coding all vendor and product IDs I had encountered with this device, and having the config tool try all known combinations until a device is found.
In a future version I may choose to enumerate all devices on the USB bus and probe all of them for the configuration endpoint.&lt;/p&gt;
&lt;p&gt;And this is where the second issue comes in:
How to reliably identify the configuration endpoint if its address changes depending on the device's configuration?
As it turned out, the description of the interface with this endpoint always was &lt;code&gt;REM&lt;/code&gt; (after some digging, I assume this is an abbreviation for Honeywell's «Remote MasterMind» application),  and only ever has a single endpoint.
Through this description, I was able to correctly identify the configuration endpoint in all device configurations that I was able to try.&lt;/p&gt;
&lt;p&gt;With these issues resolved, my configuration tool was now finally working reliably.  With this tool, a factory reset (what I had originally set out to perform), as as easy as this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="gp"&gt;$ &lt;/span&gt;honeywell-config&lt;span class="w"&gt; &lt;/span&gt;DEFOVR&lt;span class="w"&gt; &lt;/span&gt;DEFALT
&lt;span class="go"&gt;DEFOVR.&lt;/span&gt;
&lt;span class="go"&gt;DEFALT.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Apart from applying configuration, the tool can also be used to backup and restore the configuration of a barcode scanner:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="gp"&gt;$ &lt;/span&gt;honeywell-config&lt;span class="w"&gt; &lt;/span&gt;-c&lt;span class="w"&gt; &lt;/span&gt;-o&lt;span class="w"&gt; &lt;/span&gt;backup.conf
&lt;span class="gp"&gt;$ &lt;/span&gt;honeywell-config&lt;span class="w"&gt; &lt;/span&gt;-i&lt;span class="w"&gt; &lt;/span&gt;backup.conf
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;The tool and instructions for compilation and usage can be found &lt;a href="https://git.kabelsalat.ch/s3lph/honeywell-config"&gt;here&lt;/a&gt;.&lt;/p&gt;</content><category term="hacks"/><category term="Hardware"/><category term="USB"/><category term="Reverse Engineering"/></entry><entry><title>Talk: «Propagation of OpenPGP Keys using WKD, WKS and DANE»</title><link href="https://s3lph.me/vortrag-verbreitung-von-openpgp-keys.html" rel="alternate"/><published>2024-06-29T00:00:00+02:00</published><updated>2024-06-29T00:00:00+02:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2024-06-29:/vortrag-verbreitung-von-openpgp-keys.html</id><content type="html">&lt;p&gt;I held a talk at CoSin 2024 titled «Propagation of OpenPGP Keys using WKD, WKS and DANE».&lt;/p&gt;
&lt;p&gt;The slides (in German) are available in the conference archive: &lt;a href="https://fahrplan.cosin.ch/fahrplan/2024/events/fdc0738b-d56d-50b6-9138-c9d88c19c35f/attachments/resource_0.pdf"&gt;Slides&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Here's the recording of the talk (also in German):&lt;/p&gt;</content><category term="talks"/><category term="Talks"/><category term="Vorträge"/><category term="OpenPGP"/><category term="WKD"/><category term="WKS"/><category term="DANE"/><category term="DNSSEC"/><category term="E2EE"/></entry><entry><title>Vortrag: «Verbreitung von OpenPGP-Keys mit WKD, WKS und DANE»</title><link href="https://s3lph.me/vortrag-verbreitung-von-openpgp-keys-de.html" rel="alternate"/><published>2024-06-29T00:00:00+02:00</published><updated>2024-06-29T00:00:00+02:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2024-06-29:/vortrag-verbreitung-von-openpgp-keys-de.html</id><content type="html">&lt;p&gt;Ich habe auf der CoSin 2024 einen Vortrag mit dem Titel «Verbreitung von OpenPGP-Keys mit WKD, WKS und DANE» gehalten.&lt;/p&gt;
&lt;p&gt;Die Folien sind im Konferenzarchiv verfügbar: &lt;a href="https://fahrplan.cosin.ch/fahrplan/2024/events/fdc0738b-d56d-50b6-9138-c9d88c19c35f/attachments/resource_0.pdf"&gt;Slides&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Hier ist die Aufzeichnung des Vortrags:&lt;/p&gt;</content><category term="talks"/><category term="Talks"/><category term="Vorträge"/><category term="OpenPGP"/><category term="WKD"/><category term="WKS"/><category term="DANE"/><category term="DNSSEC"/><category term="E2EE"/></entry><entry><title>Issue Let's Encrypt Certificates with TSIG Zone Updates against Knot</title><link href="https://s3lph.me/issue-lets-encrypt-certificates-with-tsig-zone-updates-against-knot.html" rel="alternate"/><published>2024-05-13T21:49:00+02:00</published><updated>2024-05-13T21:49:00+02:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2024-05-13:/issue-lets-encrypt-certificates-with-tsig-zone-updates-against-knot.html</id><summary type="html">&lt;p&gt;To issue wildcard certificates, Let's Encrypt requires the use of the DNS validation method (see &lt;a href="https://letsencrypt.org/documents/isrg-cp-cps-v5.3/#3.2.2-authentication-of-organization-and-domain-identity"&gt;Certificate Policy §3.2.2&lt;/a&gt;).
I'm running my own DNS nameserver using &lt;a href="https://www.knot-dns.cz/docs/latest/html/index.html"&gt;Knot DNS&lt;/a&gt;, which does not have e.g. a REST API for updating zones.
However, Knot supports &lt;a href="https://datatracker.ietf.org/doc/html/rfc2136"&gt;RFC 2136 zone updates&lt;/a&gt;, where a …&lt;/p&gt;</summary><content type="html">&lt;p&gt;To issue wildcard certificates, Let's Encrypt requires the use of the DNS validation method (see &lt;a href="https://letsencrypt.org/documents/isrg-cp-cps-v5.3/#3.2.2-authentication-of-organization-and-domain-identity"&gt;Certificate Policy §3.2.2&lt;/a&gt;).
I'm running my own DNS nameserver using &lt;a href="https://www.knot-dns.cz/docs/latest/html/index.html"&gt;Knot DNS&lt;/a&gt;, which does not have e.g. a REST API for updating zones.
However, Knot supports &lt;a href="https://datatracker.ietf.org/doc/html/rfc2136"&gt;RFC 2136 zone updates&lt;/a&gt;, where a client submits changes in-band via DNS requests signed with a shared key.&lt;/p&gt;
&lt;h2 id="tsig-key-generation"&gt;TSIG Key Generation&lt;/h2&gt;
&lt;p&gt;To authenticate the DNS zone changes, the client needs to authenticate the request a transaction signature (or &lt;em&gt;TSIG&lt;/em&gt;) key that is shared between server and client.&lt;/p&gt;
&lt;p&gt;TSIG keys need to be named, and they need to have a "DNS compatible" name, meaning like domain names, they consist of multiple, &lt;code&gt;.&lt;/code&gt;-separated labels.  I'll simply choose &lt;code&gt;tsig.s3lph.me.&lt;/code&gt; here.&lt;/p&gt;
&lt;p&gt;Generating the TSIG key can be done easily with Knot's &lt;a href="https://www.knot-dns.cz/docs/latest/html/man_keymgr.html"&gt;&lt;code&gt;keymgr&lt;/code&gt;&lt;/a&gt; tool:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="gp"&gt;$ &lt;/span&gt;keymgr&lt;span class="w"&gt; &lt;/span&gt;-t&lt;span class="w"&gt; &lt;/span&gt;tsig.s3lph.me.&lt;span class="w"&gt; &lt;/span&gt;hmac-sha384
&lt;span class="go"&gt;key:&lt;/span&gt;
&lt;span class="go"&gt;  - id: tsig.s3lph.me.&lt;/span&gt;
&lt;span class="go"&gt;    algorithm: hmac-sha384&lt;/span&gt;
&lt;span class="go"&gt;    secret: vIZUSHoSv/n6BZ8/lqoQ5F+GZC2uuy3vU/Por8VUAJhES4WuaOHV80Z7Lv3vHlUL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;h2 id="knot-configuration"&gt;Knot Configuration&lt;/h2&gt;
&lt;p&gt;Add the following sections to Knot's configuration file (usually &lt;code&gt;/etc/knot/knot.conf&lt;/code&gt;):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="c1"&gt;# This section should exactly be the output of the `keymgr` command from above.&lt;/span&gt;
&lt;span class="c1"&gt;# If a `key` section already exists, add the new key to it.&lt;/span&gt;
&lt;span class="nt"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;tsig.s3lph.me.&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;algorithm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;hmac-sha384&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;vIZUSHoSv/n6BZ8/lqoQ5F+GZC2uuy3vU/Por8VUAJhES4WuaOHV80Z7Lv3vHlUL&lt;/span&gt;

&lt;span class="c1"&gt;# The ACL describes what the key can be used for.&lt;/span&gt;
&lt;span class="c1"&gt;# In this case the key is authorized to perform zone updates.&lt;/span&gt;
&lt;span class="c1"&gt;# Again, if an `acl` section already exists, add the new entry to the list.&lt;/span&gt;
&lt;span class="nt"&gt;acl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;acl-update-s3lph.me&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;update&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;tsig.s3lph.me.&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;# You can futher restrict the records that this key can update:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;update-type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;TXT&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;update-owner-name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;_acme-challenge&lt;/span&gt;

&lt;span class="c1"&gt;# Add the ACL to the zones you want to use the key for.&lt;/span&gt;
&lt;span class="c1"&gt;# The `acl` key can be provided multiple times and is interpreted as a list.&lt;/span&gt;
&lt;span class="nt"&gt;zones&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;s3lph.me.&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;...&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;acl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;acl-update-s3lph.me&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Finally, reload your Knot configuration with e.g. &lt;code&gt;systemctl reload knot&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="client-configuration"&gt;Client Configuration&lt;/h2&gt;
&lt;p&gt;To issue Let's Encrypt certificates using TSIG zone updates I'm using the &lt;a href="https://certbot-dns-rfc2136.readthedocs.io/en/stable/"&gt;Certbot &lt;code&gt;dns_rfc2136&lt;/code&gt; plugin&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;This plugin is not part of the default Certbot installation.  On e.g. Debian, it is however packaged as &lt;code&gt;python3-certbot-dns-rfc2136&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The plugin requires an additional config file where the TSIG key and the nameserver are configured.  I usually place it in &lt;code&gt;/etc/letsencrypt/tsig.ini&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="c1"&gt;# Target DNS server&lt;/span&gt;
&lt;span class="na"&gt;dns_rfc2136_server&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;192.0.2.42&lt;/span&gt;
&lt;span class="c1"&gt;# Target DNS port&lt;/span&gt;
&lt;span class="na"&gt;dns_rfc2136_port&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;53&lt;/span&gt;
&lt;span class="c1"&gt;# TSIG key name&lt;/span&gt;
&lt;span class="na"&gt;dns_rfc2136_name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;tsig.s3lph.me.&lt;/span&gt;
&lt;span class="c1"&gt;# TSIG key secret&lt;/span&gt;
&lt;span class="na"&gt;dns_rfc2136_secret&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;vIZUSHoSv/n6BZ8/lqoQ5F+GZC2uuy3vU/Por8VUAJhES4WuaOHV80Z7Lv3vHlUL&lt;/span&gt;
&lt;span class="c1"&gt;# TSIG key algorithm&lt;/span&gt;
&lt;span class="na"&gt;dns_rfc2136_algorithm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;HMAC-SHA384&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Remember that the TSIG key is a shared secret with which your DNS zone contents can be changed.  Protect it accordingly, e.g. through a &lt;code&gt;chmod 0600 /etc/letsencrypt/tsig.ini&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Finally, you can request your Wildcard certificate with e.g.:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="gp"&gt;# &lt;/span&gt;certbot&lt;span class="w"&gt; &lt;/span&gt;certonly&lt;span class="w"&gt; &lt;/span&gt;--dns-rfc2136&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;--dns-rfc2136-credentials&lt;span class="w"&gt; &lt;/span&gt;/etc/letsencrypt/tsig.ini&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;--dns-rfc2136-propagation-seconds&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;-d&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;*.s3lph.me&amp;#39;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;</content><category term="hacks"/><category term="Knot"/><category term="TSIG"/><category term="Let's Encrypt"/></entry><entry><title>Persisting Keycloak Sessions Across Restarts</title><link href="https://s3lph.me/persisting-keycloak-sessions-across-restarts.html" rel="alternate"/><published>2024-04-14T15:38:00+02:00</published><updated>2024-04-14T15:38:00+02:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2024-04-14:/persisting-keycloak-sessions-across-restarts.html</id><summary type="html">&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Update (2024-06-10)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://www.keycloak.org/2024/06/keycloak-2500-released.html"&gt;Starting with version 25&lt;/a&gt;, Keycloak comes with a new &lt;code&gt;persistent-user-session&lt;/code&gt; feature that stores session data in the database in addition to in memory.  I recommend enabling that feature using &lt;code&gt;features=persistent-user-session&lt;/code&gt; in &lt;code&gt;keycloak.conf&lt;/code&gt; rather than using persistent Infinispan caches.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;In my hackerspace we're running a &lt;a href="https://www.keycloak.org/"&gt;Keycloak SSO …&lt;/a&gt;&lt;/p&gt;</summary><content type="html">&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Update (2024-06-10)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://www.keycloak.org/2024/06/keycloak-2500-released.html"&gt;Starting with version 25&lt;/a&gt;, Keycloak comes with a new &lt;code&gt;persistent-user-session&lt;/code&gt; feature that stores session data in the database in addition to in memory.  I recommend enabling that feature using &lt;code&gt;features=persistent-user-session&lt;/code&gt; in &lt;code&gt;keycloak.conf&lt;/code&gt; rather than using persistent Infinispan caches.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;In my hackerspace we're running a &lt;a href="https://www.keycloak.org/"&gt;Keycloak SSO&lt;/a&gt; service to
which most of our other member services are hooked up to.  Keycloak is
set up as a single instance, so by default all user sessions are lost
in the case of a restart, and everybody has do sign in again.&lt;/p&gt;
&lt;p&gt;At the same time, we're doing automated upgrades every time a new
Keycloak version is released, so restarts (with session loss) occur
quite often, and users start complaining.&lt;/p&gt;
&lt;p&gt;Keycloak does offer the option to persist user sessions to disk, but
all guides I could find online were either outdated and still referred
to the old Wildfly version of Keycloak, or were incomplete and/or
simply broken.  The closest solution to the actual truth was a post in
the &lt;a href="https://keycloak.discourse.group/t/persist-infinispan-cache/16699/8"&gt;Keycloak Discourse forum&lt;/a&gt;, but even that one didn't
work out of the box for me. So here's the config with which it got it
to work with Keycloak Quarkus 24.&lt;/p&gt;
&lt;h2 id="keycloak-configuration-changes"&gt;Keycloak Configuration Changes&lt;/h2&gt;
&lt;p&gt;First of all, the Infinispan caching of Keycloak must be enabled, and
pointed to the XML config file bundled with Keycloak.  For this, the
following lines need to be added to &lt;code&gt;conf/keycloak.conf&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="c1"&gt;# Enable Infinispan Cache&lt;/span&gt;
&lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;ispn&lt;/span&gt;
&lt;span class="c1"&gt;# Load Infinispan config from cache-ispn.xml rather than builtin default&lt;/span&gt;
&lt;span class="na"&gt;cache-config-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;cache-ispn.xml&lt;/span&gt;
&lt;span class="c1"&gt;# Switch the JGroups default stack to TCP (more on that later)&lt;/span&gt;
&lt;span class="na"&gt;cache-stack&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;tcp&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Next, a few changes to &lt;code&gt;conf/cache-ispn.xml&lt;/code&gt; are required.  First of
all, since this is a single-node instance, we don't want JGroups to
probe for other cluster members via multicast pings, therefore we
override the default &lt;code&gt;tcp&lt;/code&gt; stack with a static node list containing
only localhost.  For this, add the following config snippet as the
first child element of the root &lt;code&gt;&amp;lt;infinispan&amp;gt;&lt;/code&gt; node:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;jgroups&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;stack&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;tcp-static&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;extends=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;tcp&amp;quot;&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;TCP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;bind_addr=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;127.0.0.1&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;bind_port=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;7800&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;TCPPING&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;initial_hosts=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;127.0.0.1[7800]&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;                 &lt;/span&gt;&lt;span class="na"&gt;port_range=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;0&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;                 &lt;/span&gt;&lt;span class="na"&gt;stack.combine=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;REPLACE&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;                 &lt;/span&gt;&lt;span class="na"&gt;stack.position=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;MPING&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;                 &lt;/span&gt;&lt;span class="na"&gt;max_dynamic_hosts=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;0&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/stack&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/jgroups&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;This stack named &lt;code&gt;tcp-static&lt;/code&gt; needs to be enabled by overriding the
stack name in the &lt;code&gt;infinispan.cache-container.transport&lt;/code&gt; element:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;transport&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;lock-timeout=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;60000&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;stack=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;tcp-static&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Now, for session persistence: First we need to tell Infinispan where it
can store its global state.  Add the following snippet as the first
child element of the &lt;code&gt;infinispan.cache-container&lt;/code&gt; node (you can choose
your own path of course):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;global-state&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;persistent-location&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;path=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;/opt/keycloak/cache/&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/global-state&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Finally, every single cache defined as a &lt;code&gt;distributed-cache&lt;/code&gt; element
in the XML file needs to get a &lt;code&gt;persistence&lt;/code&gt; section added to it,
telling it where to persist its data.  Here for example the config of
the &lt;code&gt;authenticationSessions&lt;/code&gt; cache:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;distributed-cache&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;authenticationSessions&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;owners=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;2&amp;quot;&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;expiration&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;lifespan=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;-1&amp;quot;&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;persistence&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;passivation=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;true&amp;quot;&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;file-store&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;shared=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;false&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/persistence&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/distributed-cache&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Repeat this for every single &lt;code&gt;distributed-cache&lt;/code&gt; entry in the XML
file.  The caches will be placed in a folder named after the cache
name inside the path specified in the &lt;code&gt;global-state&lt;/code&gt; section.&lt;/p&gt;
&lt;p&gt;For Keycloak 24, this results in &lt;a href="https://s3lph.me/files/2024-04-14-keycloak-cache-ispn.xml"&gt;this config file&lt;/a&gt;.  Note
that this config may require changes for major version upgrades.&lt;/p&gt;
&lt;h2 id="final-steps"&gt;Final Steps&lt;/h2&gt;
&lt;p&gt;It appears that Quarkus does not pick up changes to the
&lt;code&gt;cache-ispn.xml&lt;/code&gt; file reliably during auto-build.  An explicit build
was required after each config change and before restarting Keycloak:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="gp"&gt;keycloak:~$ &lt;/span&gt;bin/kc.sh&lt;span class="w"&gt; &lt;/span&gt;build
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;To test whether session persistence is working properly, I followed
this approach:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Pick two Keycloak clients to test with, and make sure you're signed
   out of both of them.  Also, make sure you don't have an active
   Keycloak session.&lt;/li&gt;
&lt;li&gt;Sign into the first client via SSO.  You should get redirected to
   Keycloak's login interface.  Complete the login and return to the
   first client.&lt;/li&gt;
&lt;li&gt;Restart Keycloak and wait for it to have started fully.&lt;/li&gt;
&lt;li&gt;Sign into the second client via SSO.  Login should complete
   automatically, without any password prompt from Keycloak.&lt;/li&gt;
&lt;/ol&gt;</content><category term="hacks"/><category term="Keycloak"/><category term="OIDC"/></entry><entry><title>Talk: «Sending APRS using an Arduino»</title><link href="https://s3lph.me/vortrag-aprs-arduino.html" rel="alternate"/><published>2023-06-17T00:00:00+02:00</published><updated>2023-06-17T00:00:00+02:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2023-06-17:/vortrag-aprs-arduino.html</id><content type="html">&lt;p&gt;I held a talk at CoSin 2023 titled «Sending APRS using an Arduino».&lt;/p&gt;
&lt;p&gt;The talk was not recorded, but the slides (in German) are available in the conference archive:
&lt;a href="https://fahrplan.cosin.ch/fahrplan/2023/events/f2411dff-803b-5f63-a630-d40396228eb0/attachments/resource_0.pdf"&gt;Slides&lt;/a&gt;&lt;/p&gt;</content><category term="talks"/><category term="Talks"/><category term="Vorträge"/><category term="Arduino"/><category term="APRS"/><category term="Amateurfunk"/><category term="Amateur Radio"/></entry><entry><title>Talk: «Automating DNSSEC using Knot &amp; Child DS»</title><link href="https://s3lph.me/vortrag-dnssec-automatisieren.html" rel="alternate"/><published>2023-06-17T00:00:00+02:00</published><updated>2023-06-17T00:00:00+02:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2023-06-17:/vortrag-dnssec-automatisieren.html</id><content type="html">&lt;p&gt;I held a talk at CoSin 2023 titled «Automating DNSSEC using Knot &amp;amp; Child DS».&lt;/p&gt;
&lt;p&gt;The talk was not recorded, but the slides (in German) are available in the conference archive:
&lt;a href="https://fahrplan.cosin.ch/fahrplan/2023/events/076486a0-e083-5ecf-92ca-d309e905eedb/attachments/resource_0.pdf"&gt;Slides&lt;/a&gt;&lt;/p&gt;</content><category term="talks"/><category term="Talks"/><category term="Vorträge"/><category term="DNS"/><category term="DNSSEC"/><category term="Knot"/></entry><entry><title>Vortrag: «APRS senden mit dem Arduino»</title><link href="https://s3lph.me/vortrag-aprs-arduino-de.html" rel="alternate"/><published>2023-06-17T00:00:00+02:00</published><updated>2023-06-17T00:00:00+02:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2023-06-17:/vortrag-aprs-arduino-de.html</id><content type="html">&lt;p&gt;Ich habe auf der CoSin 2023 einen Vortrag mit dem Titel «APRS senden mit dem Arduino» gehalten.&lt;/p&gt;
&lt;p&gt;Der Vortrag wurde nicht aufgezeichnet, aber die Folien sind im Konferenzarchiv verfügbar:
&lt;a href="https://fahrplan.cosin.ch/fahrplan/2023/events/f2411dff-803b-5f63-a630-d40396228eb0/attachments/resource_0.pdf"&gt;Slides&lt;/a&gt;&lt;/p&gt;</content><category term="talks"/><category term="Talks"/><category term="Vorträge"/><category term="Arduino"/><category term="APRS"/><category term="Amateurfunk"/><category term="Amateur Radio"/></entry><entry><title>Vortrag: «DNSSEC automatisieren mit Knot &amp; Child DS»</title><link href="https://s3lph.me/vortrag-dnssec-automatisieren-de.html" rel="alternate"/><published>2023-06-17T00:00:00+02:00</published><updated>2023-06-17T00:00:00+02:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2023-06-17:/vortrag-dnssec-automatisieren-de.html</id><content type="html">&lt;p&gt;Ich habe auf der CoSin 2023 einen Vortrag mit dem Titel «DNSSEC
automatisieren mit Knot &amp;amp; Child DS» gehalten.&lt;/p&gt;
&lt;p&gt;Der Vortrag wurde nicht aufgezeichnet, aber die Folien sind im Konferenzarchiv verfügbar:
&lt;a href="https://fahrplan.cosin.ch/fahrplan/2023/events/076486a0-e083-5ecf-92ca-d309e905eedb/attachments/resource_0.pdf"&gt;Slides&lt;/a&gt;&lt;/p&gt;</content><category term="talks"/><category term="Talks"/><category term="Vorträge"/><category term="DNS"/><category term="DNSSEC"/><category term="Knot"/></entry><entry><title>Mein Einbürgerungverfahren in Basel - Teil 7: Fazit</title><link href="https://s3lph.me/einbuergerungsverfahren-in-basel-teil-7-fazit-de.html" rel="alternate"/><published>2023-03-31T01:30:00+02:00</published><updated>2023-03-31T01:30:00+02:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2023-03-31:/einbuergerungsverfahren-in-basel-teil-7-fazit-de.html</id><summary type="html">&lt;p&gt;&lt;strong&gt;Achtung&lt;/strong&gt;: Die Informationen in diesem Artikel beziehen sich auf das
Verfahren im Kanton Basel-Stadt, die Verfahren in anderen Kantonen
weichen z.T. stark ab.&lt;/p&gt;
&lt;h2 id="personliche-eindrucke"&gt;Persönliche Eindrücke&lt;/h2&gt;
&lt;p&gt;Im Grossen und Ganzen habe ich das Einbürgerungsverfahren als deutlich
«harmloser» empfunden, als ich es erwartet hatte.  Insbesondere beim
Gespräch mit der Einbürgerungskommission hatte …&lt;/p&gt;</summary><content type="html">&lt;p&gt;&lt;strong&gt;Achtung&lt;/strong&gt;: Die Informationen in diesem Artikel beziehen sich auf das
Verfahren im Kanton Basel-Stadt, die Verfahren in anderen Kantonen
weichen z.T. stark ab.&lt;/p&gt;
&lt;h2 id="personliche-eindrucke"&gt;Persönliche Eindrücke&lt;/h2&gt;
&lt;p&gt;Im Grossen und Ganzen habe ich das Einbürgerungsverfahren als deutlich
«harmloser» empfunden, als ich es erwartet hatte.  Insbesondere beim
Gespräch mit der Einbürgerungskommission hatte ich zum einen
schwierigere Wissensfragen, und zum anderen mehr Fragen zum
persönlichen Leben erwartet.  Diese Erwartung war vor allem von
diversen Berichten geprägt, die in den letzten Jahren durch die Medien
gegangen sind, wie z.B. das international bekannt gewordene
&lt;a href="https://www.aargauerzeitung.ch/thema/funda-yilmaz"&gt;Einbürgerungsverfahren von Fulda Yilmaz aus Buchs
AG&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Allerdings bin ich mir auch bewusst, dass ich die Einbürgerung quasi
«auf einfach durchgespielt» habe: Da ich Deutsch als Muttersprache
spreche, musste ich keinen Sprachtest ablegen, und als weisser Mann
blieben mir auch Begegnungen mit den Behörden erspart, von denen man
in anderen Berichten liest, und die &lt;a href="https://www.srf.ch/news/schweiz/einbuergerungen-im-dorf-der-strengen-schweizermacher"&gt;z.T. offen diskriminierend
sind&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Als ich angefangen habe, mich über das Einbürgerungsverfahren zu
informieren, waren anfangs noch sehr viele Punkte unklar -
insbesondere, welche Dokumente einzureichen sind.  Das lag zwar unter
anderem daran, dass z.B. die Checkliste der einzureichenden Dokumente
nicht online verfügbar war (siehe &lt;a href="einbuergerungsverfahren-in-basel-teil-2-vorbereitung-gesuchsstellung-de.html"&gt;Teil
2&lt;/a&gt;),
aber vor allem auch dran, dass je nach Staatsbürgerschaft und
Aufenthaltssituation unterschiedliche Unterlagen benötigt werden.  Die
Leute in den Behörden waren allerdings allesamt sehr hilfsbereit, und
konnten alle Fragen beantworten.&lt;/p&gt;
&lt;h2 id="timeline"&gt;Timeline&lt;/h2&gt;
&lt;dl class="timeline"&gt;
&lt;dt&gt;&lt;time date="2021-07-12"&gt;12.07.2021&lt;/time&gt;&lt;/dt&gt;
&lt;dd&gt;Beratung vom Migrationsamt via E-Mail&lt;/dd&gt;
&lt;dt class="p2m"&gt;&lt;time date="2021-09-17"&gt;17.09.2021&lt;/time&gt;&lt;/dt&gt;
&lt;dd&gt;&lt;a href="/einbuergerungsverfahren-in-basel-teil-2-vorbereitung-gesuchsstellung-de.html"&gt;Einreichung des Einbürgerungsgesuchs auf dem Postweg&lt;/a&gt;&lt;/dd&gt;
&lt;dt&gt;&lt;time date="2021-09-24"&gt;24.09.2021&lt;/time&gt;&lt;/dt&gt;
&lt;dd&gt;Eingangsbestätigung vom kantonalen Migrationsamt&lt;/dd&gt;
&lt;dt class="p5m"&gt;&lt;time date="2022-02-24"&gt;24.02.2022&lt;/time&gt;&lt;/dt&gt;
&lt;dd&gt;Einladung zum Erhebungsgespräch&lt;/dd&gt;
&lt;dt&gt;&lt;time date="2022-03-01"&gt;01.03.2022&lt;/time&gt;&lt;/dt&gt;
&lt;dd&gt;&lt;a href="/einbuergerungsverfahren-in-basel-teil-3-erhebungsgespraech-de.html"&gt;Telefonisches Erhebungsgespräch&lt;/a&gt;&lt;/dd&gt;
&lt;dt&gt;&lt;time date="2022-03-08"&gt;08.03.2022&lt;/time&gt;&lt;/dt&gt;
&lt;dd&gt;Übergabe des Gesuchs vom Kanton an die Bürgergemeinde Basel&lt;/dd&gt;
&lt;dt class="p3m"&gt;&lt;time date="2022-06-03"&gt;03.06.2022&lt;/time&gt; &amp;ndash; &lt;time date="2022-06-17"&gt;17.06.2022&lt;/time&gt;&lt;/dt&gt;
&lt;dd&gt;&lt;a href="/einbuergerungsverfahren-in-basel-teil-4-einbuergerungskurs-de.html"&gt;«Einbürgerungskurs» bei der Bürgergemeinde&lt;/a&gt;&lt;/dd&gt;
&lt;dt class="p1m"&gt;&lt;time date="2022-07-29"&gt;29.07.2022&lt;/time&gt;&lt;/dt&gt;
&lt;dd&gt;Einladung zum Einbürgerungsgespräch&lt;/dd&gt;
&lt;dt class="p1m"&gt;&lt;time date="2022-08-26"&gt;26.08.2022&lt;/time&gt;&lt;/dt&gt;
&lt;dd&gt;&lt;a href="/einbuergerungsverfahren-in-basel-teil-5-einbuergerungsgespraech-de.html"&gt;Einbürgerungsgespräch&lt;/a&gt;&lt;/dd&gt;
&lt;dt&gt;&lt;time date="2022-08-27"&gt;27.08.2022&lt;/time&gt;&lt;/dt&gt;
&lt;dd&gt;Schriftlicher Einbürgerungsbescheid der Bürgergemeinde Basel&lt;/dd&gt;
&lt;dt class="p2m"&gt;&lt;time date="2022-10-26"&gt;26.10.2022&lt;/time&gt;&lt;/dt&gt;
&lt;dd&gt;Übergabe des Gesuchs vom der Bürgergemeinde Basel an den Bund&lt;/dd&gt;
&lt;dt class="p3m"&gt;&lt;time date="2023-01-31"&gt;31.01.2023&lt;/time&gt;&lt;/dt&gt;
&lt;dd&gt;Erwerb des Bürgerrechts&lt;/dd&gt;
&lt;dt class="p1m"&gt;&lt;time date="2023-02-17"&gt;17.02.2023&lt;/time&gt;&lt;/dt&gt;
&lt;dd&gt;&lt;a href="/einbuergerungsverfahren-in-basel-teil-6-abschluss-des-verfahrens-de.html"&gt;Schriftliche Information des Migrationsamts über den Erwerb des Bürgerrechts&lt;/a&gt;&lt;/dd&gt;
&lt;/dl&gt;

&lt;h2 id="kostenaufstellung"&gt;Kostenaufstellung&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="text-align: left;"&gt;Kostenpunkt&lt;/th&gt;
&lt;th style="text-align: right;"&gt;Betrag (CHF)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;Bestellung von diversen Dokumenten&lt;/td&gt;
&lt;td style="text-align: right;"&gt;~ 200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;Verfahrenskosten Kanton Basel-Stadt&lt;/td&gt;
&lt;td style="text-align: right;"&gt;600&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;Verfahrenskosten Bürgergemeinde Basel&lt;/td&gt;
&lt;td style="text-align: right;"&gt;950&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;Verfahrenskosten Bund&lt;/td&gt;
&lt;td style="text-align: right;"&gt;100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;Einbürgerungskurs&lt;/td&gt;
&lt;td style="text-align: right;"&gt;120&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;---&lt;/td&gt;
&lt;td style="text-align: right;"&gt;---&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: right;"&gt;&lt;strong&gt;1'970&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;</content><category term="life"/><category term="Einbürgerung"/><category term="Schweiz"/><category term="Basel"/></entry><entry><title>Mein Einbürgerungverfahren in Basel - Teil 6: Abschluss des Verfahrens</title><link href="https://s3lph.me/einbuergerungsverfahren-in-basel-teil-6-abschluss-des-verfahrens-de.html" rel="alternate"/><published>2023-03-31T00:30:00+02:00</published><updated>2023-03-31T00:30:00+02:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2023-03-31:/einbuergerungsverfahren-in-basel-teil-6-abschluss-des-verfahrens-de.html</id><summary type="html">&lt;p&gt;Gegen Ende Februar habe ich vom Migrationsamt des Kantons Basel-Stadt
einen doch eher unzeremoniellen Brief bekommen, der mich darüber
informierte, dass mir am 31. Januar 2023 das Bürgerrecht der Stadt
Basel, des Kantons Basel-Stadt und der Schweiz verliehen wurde.&lt;/p&gt;
&lt;p&gt;Damit ist das Einbürgerungsverfahren an sich nun abgeschlossen; als
«frischgebackener» Schweizerbürger …&lt;/p&gt;</summary><content type="html">&lt;p&gt;Gegen Ende Februar habe ich vom Migrationsamt des Kantons Basel-Stadt
einen doch eher unzeremoniellen Brief bekommen, der mich darüber
informierte, dass mir am 31. Januar 2023 das Bürgerrecht der Stadt
Basel, des Kantons Basel-Stadt und der Schweiz verliehen wurde.&lt;/p&gt;
&lt;p&gt;Damit ist das Einbürgerungsverfahren an sich nun abgeschlossen; als
«frischgebackener» Schweizerbürger bin ich nun unter anderem stimm-
und wahlberechtigt.  An den kantonalen Abstimmungen vom 12. März
konnte ich aber leider noch nicht teilnehmen.&lt;/p&gt;
&lt;p&gt;Der Brief vom Migrationsamt enthielt auch Informationen über das
weitere Vorgehen: Ich könne nun eine Identitätskarte und Schweizerpass
beantragen, und zudem würde ich demnächst noch eine Einladung der
Bürgergemeinde Basel zur Überreichung des Bürgerbriefs erhalten.&lt;/p&gt;
&lt;h2 id="bestellung-von-id-und-pass"&gt;Bestellung von ID und Pass&lt;/h2&gt;
&lt;p&gt;Den Termin zur Beantragung von ID («Identitätskarte») und/oder Pass
kann unter &lt;code&gt;http://www.schweizerpass.ch/&lt;/code&gt; online gebucht werden.  Und
ja, das &lt;code&gt;http://www.&lt;/code&gt; ist wichtig!  Der Link ist nur mit
&lt;code&gt;www&lt;/code&gt;-Subdomain und ohne HTTPS erreichbar.  Dabei handelt es sich
allerdings nur um eine Weiterleitung auf die Website des fedpol, und
diese kann auch HTTPS.&lt;/p&gt;
&lt;p&gt;Nach der Eingabe der benötigten Daten erhielt ich dann ein E-Mail vom
kantonalen Passamt mit der Aufforderung, einen Termin zur persönlichen
Vorsprache und Aufnahme der biometrischen Daten (Passfoto und
Fingerabdrücke) auszuwählen.  Den frühesten Termin gab es allerdings
erst Mitte März.  Das E-Mail informierte mich auch direkt darüber,
dass ich zum Termin lediglich meinen bisherigen Ausländerausweis
mitbringen müsse.&lt;/p&gt;
&lt;p&gt;Bereits zwei Tage nach dem Termin bekam ich ID und Pass in der Post.&lt;/p&gt;
&lt;h2 id="die-sache-mit-der-wehrpflicht"&gt;Die Sache mit der Wehrpflicht&lt;/h2&gt;
&lt;p&gt;Am gleichen Tag bekam ich auch Post von der kantonalen Militärbehörde,
mit der Information, dass ich durch die Einbürgerung
militärdienstpflichtig geworden bin.  Da ich aber bereits über 25
Jahre alt bin, muss ich stattdessen Zivilschutzdienst leisten, sowie
eine einkommensbemessene (durch den Zivilschutzdienst reduzierte)
Wehrpflichtersatzabgabe zahlen.  Dazu habe ich gleich noch die
Information bekommen, dass ich bereits im August zur
Zivilschutz-Rekrutierung aufgeboten werde.&lt;/p&gt;</content><category term="life"/><category term="Einbürgerung"/><category term="Schweiz"/><category term="Basel"/></entry><entry><title>Migration from LDAP to OIDC: Matrix</title><link href="https://s3lph.me/ldap-to-oidc-migration-3-matrix.html" rel="alternate"/><published>2023-01-27T01:25:00+01:00</published><updated>2023-01-27T01:25:00+01:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2023-01-27:/ldap-to-oidc-migration-3-matrix.html</id><summary type="html">&lt;p&gt;In my hackerspace we operate various services for our members.  Up
until this month, most of these services used to do user
authentication against a LDAP server.  For a multitude of reasons, we
replaced the LDAP server with an &lt;a href="https://openid.net/connect/"&gt;OpenID Connect&lt;/a&gt; &lt;abbr
title="Single Sign-On"&gt;SSO&lt;/abbr&gt; using a &lt;a href="https://www.keycloak.org/"&gt;Keycloak&lt;/a&gt; server
as the OIDC Identity …&lt;/p&gt;</summary><content type="html">&lt;p&gt;In my hackerspace we operate various services for our members.  Up
until this month, most of these services used to do user
authentication against a LDAP server.  For a multitude of reasons, we
replaced the LDAP server with an &lt;a href="https://openid.net/connect/"&gt;OpenID Connect&lt;/a&gt; &lt;abbr
title="Single Sign-On"&gt;SSO&lt;/abbr&gt; using a &lt;a href="https://www.keycloak.org/"&gt;Keycloak&lt;/a&gt; server
as the OIDC Identity Provider.&lt;/p&gt;
&lt;p&gt;In this series of articles I summarize the efforts required to migrate
each of the services from LDAP to OIDC authentication.  This article
covers the setup and migration in the Matrix homeserver Synapse.&lt;/p&gt;
&lt;h2 id="oidc-setup-in-synapse"&gt;OIDC Setup in Synapse&lt;/h2&gt;
&lt;p&gt;Adding an OIDC provider in &lt;a href="https://matrix.org/docs/projects/server/synapse"&gt;Synapse&lt;/a&gt; is accomplished by
adding its definition to the &lt;code&gt;homeserver.yaml&lt;/code&gt; configuration file:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nt"&gt;oidc_providers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;idp_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;quot;oidc&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;idp_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;quot;Human&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Readable&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Name&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;quot;https://sso.example.org/realms/example/&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;quot;oidc-client-id-matrix&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;client_secret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;quot;oidc-client-secret-matrix&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;scopes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p p-Indicator"&gt;[&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;openid&amp;quot;&lt;/span&gt;&lt;span class="p p-Indicator"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;quot;profile&amp;quot;&lt;/span&gt;&lt;span class="p p-Indicator"&gt;]&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;allow_existing_users&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;true&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;user_mapping_provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;localpart_template&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;quot;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;user.preferred_username&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;confirm_localpart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;The crucial setting here is &lt;code&gt;allow_existing_users: true&lt;/code&gt;; this is what
enables migration from LDAP with OIDC.  Synapse stores all users in
the same database, and providers such as LDAP or OIDC are primarily
used for authentication and initial account creation.  This makes the
migration in Synapse pretty straight-forward: as long as the LDAP
provider and OIDC provider maps the user to the same Matrix ID
localpart (the &lt;code&gt;s3lph&lt;/code&gt; in &lt;code&gt;@s3lph:kabelsalat.ch&lt;/code&gt;), they map to the
same user and can be used interchangeably.&lt;/p&gt;
&lt;p&gt;How the localpart is derived from the OIDC &lt;code&gt;id_token&lt;/code&gt; is configured in
the &lt;code&gt;localpart_template&lt;/code&gt; setting.  This setting takes a Jinja2
template where the token claims are available in the &lt;code&gt;user&lt;/code&gt; object.
So the example above uses the &lt;code&gt;preferred_username&lt;/code&gt; token claim.&lt;/p&gt;
&lt;p&gt;Even though I have not tested this, I'm fairly confident that a
migration should work even when the &lt;code&gt;localpart_template&lt;/code&gt; does not
match the existing users' localparts.  However, then you'd have to
fill the &lt;code&gt;user_external_ids&lt;/code&gt; database table with the mappings between
OIDC subject identifiers (the &lt;code&gt;sub&lt;/code&gt; token claim) and the full Matrix
ID (&lt;code&gt;@localpart:example.org&lt;/code&gt;).  This table is already populated each
time a user signs in via OIDC:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="go"&gt;synapse=# select * from user_external_ids;&lt;/span&gt;
&lt;span class="go"&gt; auth_provider |             external_id              |       user_id&lt;/span&gt;
&lt;span class="go"&gt;---------------+--------------------------------------+----------------------&lt;/span&gt;
&lt;span class="go"&gt; oidc          | ee5ff004-07c7-4c8f-b609-298aa1b5cd88 | @s3lph:kabelsalat.ch&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;You can also allow new users on your homeserver to choose a different
username when they sign in via OIDC for the first time.  This is
controlled by the &lt;code&gt;confirm_localpart&lt;/code&gt; setting in the example above.
This too is controlled by entries in the &lt;code&gt;user_external_ids&lt;/code&gt; table.&lt;/p&gt;
&lt;p&gt;This architecture of only using LDAP and OIDC as an authentication
providers and decoupling them from the actual users in this way,
alongside with the &lt;code&gt;allow_existing_users&lt;/code&gt; OIDC provider setting made
Synapse the easiest service to migrate.  If you can even call it
"migration", that is - you simply add the OIDC provider and later
remove the LDAP provider and you're done.&lt;/p&gt;
&lt;h2 id="limitations"&gt;Limitations&lt;/h2&gt;
&lt;p&gt;Unfortunately SSO handling in Matrix heavily depends on the specific
clients your users are using, with greatly varying behaviors:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Some clients don't support SSO at all. Check the &lt;a href="https://matrix.org/clients-matrix/"&gt;client feature
  matrix&lt;/a&gt; to see which clients support SSO
  login.&lt;/li&gt;
&lt;li&gt;Some clients only support SSO if the default password
  authentication flow, &lt;code&gt;m.login.password&lt;/code&gt;, is disabled.  However,
  if you need password login for some local users, disabling it may
  not be an option for you.&lt;/li&gt;
&lt;li&gt;However, most clients I tested properly implement the &lt;code&gt;m.login.sso&lt;/code&gt;
  authentication flow and give the user the option to sign in via SSO.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You can disable the &lt;code&gt;m.login.password&lt;/code&gt; flow if you don't need it by
adding the following to your &lt;code&gt;homeserver.yaml&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nt"&gt;password_config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;</content><category term="hacks"/><category term="Migration"/><category term="OIDC"/><category term="Matrix"/><category term="Synapse"/></entry><entry><title>Migration from LDAP to OIDC: Nextcloud</title><link href="https://s3lph.me/ldap-to-oidc-migration-2-nextcloud.html" rel="alternate"/><published>2023-01-26T22:00:00+01:00</published><updated>2023-01-26T22:00:00+01:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2023-01-26:/ldap-to-oidc-migration-2-nextcloud.html</id><summary type="html">&lt;p&gt;In my hackerspace we operate various services for our members.  Up
until this month, most of these services used to do user
authentication against a LDAP server.  For a multitude of reasons, we
replaced the LDAP server with an &lt;a href="https://openid.net/connect/"&gt;OpenID Connect&lt;/a&gt; &lt;abbr
title="Single Sign-On"&gt;SSO&lt;/abbr&gt; using a &lt;a href="https://www.keycloak.org/"&gt;Keycloak&lt;/a&gt; server
as the OIDC Identity …&lt;/p&gt;</summary><content type="html">&lt;p&gt;In my hackerspace we operate various services for our members.  Up
until this month, most of these services used to do user
authentication against a LDAP server.  For a multitude of reasons, we
replaced the LDAP server with an &lt;a href="https://openid.net/connect/"&gt;OpenID Connect&lt;/a&gt; &lt;abbr
title="Single Sign-On"&gt;SSO&lt;/abbr&gt; using a &lt;a href="https://www.keycloak.org/"&gt;Keycloak&lt;/a&gt; server
as the OIDC Identity Provider.&lt;/p&gt;
&lt;p&gt;In this series of articles I summarize the efforts required to migrate
each of the services from LDAP to OIDC authentication.  This article
covers the setup and migration in Nextcloud.&lt;/p&gt;
&lt;h2 id="oidc-setup-in-nextcloud"&gt;OIDC Setup in Nextcloud&lt;/h2&gt;
&lt;p&gt;While LDAP integration is part of the &lt;a href="https://nextcloud.com/"&gt;Nextcloud&lt;/a&gt; core,
OIDC integration must be installed as an additional app,
&lt;a href="https://apps.nextcloud.com/apps/user_oidc"&gt;&lt;code&gt;user_oidc&lt;/code&gt;&lt;/a&gt;.  Once this app is installed and enabled
(&lt;code&gt;occ app:enable user_oidc&lt;/code&gt;), you'll find a new tab in Nextcloud's
admin settings called "OpenID Connect", where you can add your
Identity Provider.&lt;/p&gt;
&lt;p&gt;When adding a new IdP, you need to pay attention to choose an user ID
claim that matches the user IDs from LDAP: If you used the &lt;code&gt;uid&lt;/code&gt; or
&lt;code&gt;sAMAccountName&lt;/code&gt; attribute in the LDAP backend, chances are you can
simply use the &lt;code&gt;preferred_username&lt;/code&gt; claim in the OIDC backend and call
it a day.  (Please test whether these indeed match for all users!)
However, if you used e.g. the &lt;code&gt;objectGUID&lt;/code&gt; LDAP attribute, you need to
make sure that the same attribute is available as an OIDC token claim.&lt;/p&gt;
&lt;p&gt;Finally, remove the &lt;code&gt;Use unique user id&lt;/code&gt; check mark, otherwise the OIDC
backend generates its own user IDs anyway (by using a hash over the
OIDC username and the name of the IdP).&lt;/p&gt;
&lt;h2 id="migration-of-users-from-ldap-to-oidc"&gt;Migration of Users From LDAP to OIDC&lt;/h2&gt;
&lt;p&gt;Users in Nextcloud are not managed in one central database table.
Instead, each user backend maintains its own set of users and mapping
to Nextcloud user IDs, usually each using its own database table, such as:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;oc_users&lt;/code&gt;: Users created directly in Nextcloud.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;oc_ldap_user_mapping&lt;/code&gt;: Users maintained by the &lt;code&gt;user_ldap&lt;/code&gt; backend
  and the mapping from LDAP DNs to Nextcloud usernames.&lt;ul&gt;
&lt;li&gt;Groups and group memberships synced from LDAP are stored in
  &lt;code&gt;oc_ldap_group_mapping&lt;/code&gt; and &lt;code&gt;oc_ldap_group_members&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;oc_user_oidc&lt;/code&gt;: Users maintained by the &lt;code&gt;user_oidc&lt;/code&gt; backend.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is why, as soon as the &lt;code&gt;user_ldap&lt;/code&gt; app is disabled, all users and
groups that were synced from LDAP disappear immediately.  Don't
worry - the users aren't gone.  If you re-enable the &lt;code&gt;user_ldap&lt;/code&gt; app,
they automagically reappear.  But the LDAP user mappings need to be
migrated to the &lt;code&gt;oc_user_oidc&lt;/code&gt; table so that the users are still
around when the LDAP backend is disabled.  For this we first need to
understand the table schemas:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="c"&gt;MariaDB &lt;/span&gt;&lt;span class="k"&gt;[&lt;/span&gt;&lt;span class="c"&gt;nextcloud&lt;/span&gt;&lt;span class="k"&gt;]&lt;/span&gt;&lt;span class="nv"&gt;&amp;gt;&lt;/span&gt;&lt;span class="c"&gt; select * from oc_ldap_user_mapping;&lt;/span&gt;
&lt;span class="nb"&gt;+--------------------------------------+---------------+----------------+------------------------------------------------------------------+&lt;/span&gt;
&lt;span class="c"&gt;| ldap_dn                              | owncloud_name | directory_uuid | ldap_dn_hash                                                     |&lt;/span&gt;
&lt;span class="nb"&gt;+--------------------------------------+---------------+----------------+------------------------------------------------------------------+&lt;/span&gt;
&lt;span class="c"&gt;| uid=s3lph&lt;/span&gt;&lt;span class="nt"&gt;,&lt;/span&gt;&lt;span class="c"&gt;cn=users&lt;/span&gt;&lt;span class="nt"&gt;,&lt;/span&gt;&lt;span class="c"&gt;dc=example&lt;/span&gt;&lt;span class="nt"&gt;,&lt;/span&gt;&lt;span class="c"&gt;dc=org | s3lph         | s3lph          | faea045b8b1be34c7e02fa0771f3a2d6b7cbad4297a68c3c3fc1c39ff3cd2988 |&lt;/span&gt;
&lt;span class="c"&gt;| uid=s4lph&lt;/span&gt;&lt;span class="nt"&gt;,&lt;/span&gt;&lt;span class="c"&gt;cn=users&lt;/span&gt;&lt;span class="nt"&gt;,&lt;/span&gt;&lt;span class="c"&gt;dc=example&lt;/span&gt;&lt;span class="nt"&gt;,&lt;/span&gt;&lt;span class="c"&gt;dc=org | s4lph         | s4lph          | 14df2aecf103280c8fd693b77959f7fdab4c530fce9ebc35575d9b763fd1c5e6 |&lt;/span&gt;
&lt;span class="c"&gt;| uid=s5lph&lt;/span&gt;&lt;span class="nt"&gt;,&lt;/span&gt;&lt;span class="c"&gt;cn=users&lt;/span&gt;&lt;span class="nt"&gt;,&lt;/span&gt;&lt;span class="c"&gt;dc=example&lt;/span&gt;&lt;span class="nt"&gt;,&lt;/span&gt;&lt;span class="c"&gt;dc=org | s5lph         | s5lph          | a5938499eb735d4cdb2aebe641a155aa930bb0962003e595ce960eb2a956d10d |&lt;/span&gt;
&lt;span class="nb"&gt;+--------------------------------------+---------------+----------------+------------------------------------------------------------------+&lt;/span&gt;

&lt;span class="c"&gt;MariaDB &lt;/span&gt;&lt;span class="k"&gt;[&lt;/span&gt;&lt;span class="c"&gt;nextcloud&lt;/span&gt;&lt;span class="k"&gt;]&lt;/span&gt;&lt;span class="nv"&gt;&amp;gt;&lt;/span&gt;&lt;span class="c"&gt; select * from oc_user_oidc;&lt;/span&gt;
&lt;span class="nb"&gt;+----+-----------+--------------+&lt;/span&gt;
&lt;span class="c"&gt;| id | user_id   | display_name |&lt;/span&gt;
&lt;span class="nb"&gt;+----+-----------+--------------+&lt;/span&gt;
&lt;span class="c"&gt;|  2 | s3lph     | s3lph        |&lt;/span&gt;
&lt;span class="nb"&gt;+----+-----------+--------------+&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;In the &lt;code&gt;oc_ldap_user_mapping&lt;/code&gt; table, we have a mapping of DNs
(&lt;code&gt;ldap_dn&lt;/code&gt;) to usernames in Nextcloud (&lt;code&gt;owncloud_name&lt;/code&gt;).  The key
component here is the &lt;code&gt;owncloud_name&lt;/code&gt; column.  This is the
Nextcloud internal name, and also the name that must be provided by
the OIDC backend (i.e. it must be present as a claim in the
&lt;code&gt;id_token&lt;/code&gt;, and that claim must be chosen as the "User ID mapping"
attribute in the OIDC provider settings in Nextcloud.  The
&lt;code&gt;oc_user_oidc&lt;/code&gt; table, on the other hand, does not store any mappings
between OIDC and Nextcloud IDs.  Instead it only contains the user ID
and display name as provided by the OIDC provider.&lt;/p&gt;
&lt;p&gt;The user migration from LDAP to OIDC can then be performed with a
single SQL statement:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IGNORE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INTO&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;oc_user_oidc&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;display_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;owncloud_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;owncloud_name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;oc_ldap_user_mapping&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Though you may want to keep the display name in mind.  With our
Nextcloud instance, we used the username as display name as well, so
we simply used the same value for both columns.  I don't think the
LDAP display names are stored in the database, so if you have
different display names, this simple insert statement may not work as
well for you.&lt;/p&gt;
&lt;h2 id="limitations"&gt;Limitations&lt;/h2&gt;
&lt;p&gt;Synchronization of user groups from your OIDC IdP to Nextcloud is not
possible yet, but there's an &lt;a href="https://github.com/nextcloud/user_oidc/pull/502"&gt;open pull request&lt;/a&gt; that
implements this feature.  This means that until this is merged and
released, group memberships that have previously been synced from LDAP
won't be updated automatically anymore.  Furthermore, if/when you
disable the &lt;code&gt;user_ldap&lt;/code&gt; app, any group that was synced from LDAP will
disappear, and has to be recreated.&lt;/p&gt;
&lt;p&gt;Another limitation is that OIDC users are only created in Nextcloud
once they sign in for the first time.  This means that you e.g. can't
share files with users that have never signed in before.  If your IdP
has an automatable impersonation feature, you could work around this
issue with a workflow such as:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Make sure the option "Check Bearer token on API and WebDav
   requests" is enabled in Nextcloud's OIDC settings.&lt;/li&gt;
&lt;li&gt;Use the IdP's impersonation API to obtain an &lt;code&gt;id_token&lt;/code&gt; for the new
   user.&lt;/li&gt;
&lt;li&gt;Use this &lt;code&gt;id_token&lt;/code&gt; to perform a simple request to Nextcloud,
   e.g. a WebDAV &lt;code&gt;PROPFIND&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Nextcloud automatically creates the new user.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="tips-and-tricks"&gt;Tips and Tricks&lt;/h2&gt;
&lt;p&gt;Nextcloud does not automatically redirect to the IdP for login - users
always have to click the "Login with SSO" button.  If you want your
users to be redirected to the SSO automatically when they try to log
in, you have to configure this in your web server.  For example, in
Apache 2:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nb"&gt;RewriteEngine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;on&lt;/span&gt;
&lt;span class="nb"&gt;RewriteCond&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;%{QUERY_STRING}&lt;span class="w"&gt; &lt;/span&gt;!local=1
&lt;span class="nb"&gt;RewriteCond&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;%{REQUEST_METHOD}&lt;span class="w"&gt; &lt;/span&gt;GET
&lt;span class="nb"&gt;RewriteRule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;^/login$&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="sx"&gt;/apps/user_oidc/login/1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;[L,R,QSD]
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Replace the number at the end of the URL (&lt;code&gt;/1&lt;/code&gt;) with the ID of the
OIDC provider in Nextcloud.  You can obtain this ID with &lt;code&gt;occ
user_oidc:provider&lt;/code&gt;. The &lt;code&gt;RewriteCond&lt;/code&gt;s in this config allow you to
append &lt;code&gt;?local=1&lt;/code&gt; to disable the redirect, e.g. if you need to sign in
as a local admin user.&lt;/p&gt;</content><category term="hacks"/><category term="Migration"/><category term="OIDC"/><category term="Nextcloud"/></entry><entry><title>Migration from LDAP to OIDC: Forgejo</title><link href="https://s3lph.me/ldap-to-oidc-migration-1-forgejo.html" rel="alternate"/><published>2023-01-26T10:18:00+01:00</published><updated>2023-01-26T10:18:00+01:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2023-01-26:/ldap-to-oidc-migration-1-forgejo.html</id><summary type="html">&lt;p&gt;In my hackerspace we operate various services for our members.  Up
until this month, most of these services used to do user
authentication against a LDAP server.  For a multitude of reasons, we
replaced the LDAP server with an &lt;a href="https://openid.net/connect/"&gt;OpenID Connect&lt;/a&gt; &lt;abbr
title="Single Sign-On"&gt;SSO&lt;/abbr&gt; using a &lt;a href="https://www.keycloak.org/"&gt;Keycloak&lt;/a&gt; server
as the OIDC Identity …&lt;/p&gt;</summary><content type="html">&lt;p&gt;In my hackerspace we operate various services for our members.  Up
until this month, most of these services used to do user
authentication against a LDAP server.  For a multitude of reasons, we
replaced the LDAP server with an &lt;a href="https://openid.net/connect/"&gt;OpenID Connect&lt;/a&gt; &lt;abbr
title="Single Sign-On"&gt;SSO&lt;/abbr&gt; using a &lt;a href="https://www.keycloak.org/"&gt;Keycloak&lt;/a&gt; server
as the OIDC Identity Provider.&lt;/p&gt;
&lt;p&gt;In this series of articles I summarize the efforts required to migrate
each of the services from LDAP to OIDC authentication.  This article
covers the setup and migration in Forgejo.&lt;/p&gt;
&lt;h2 id="oidc-setup-in-forgejo"&gt;OIDC Setup in Forgejo&lt;/h2&gt;
&lt;p&gt;&lt;a href="https://forgejo.org/"&gt;Forgejo&lt;/a&gt; is a fork of the &lt;a href="https://gitea.io/"&gt;Gitea&lt;/a&gt; Git hosting
platform that has emerged recently as a result of the &lt;a href="https://gitea-open-letter.coding.social/"&gt;takeover of the
Gitea project by for-profit Gitea Ltd&lt;/a&gt;.  Since both
projects are still mostly identical at the time of writing, this
section should apply 1:1 to Gitea as well.&lt;/p&gt;
&lt;p&gt;To get started, sign into Forgejo as an admin user and create a new
&lt;strong&gt;authentication source&lt;/strong&gt; in the settings:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Choose &lt;code&gt;OAuth2&lt;/code&gt; as the authentication type and &lt;code&gt;OpenID Connect&lt;/code&gt; as OAuth2 provider.&lt;/li&gt;
&lt;li&gt;Fill in the Client ID and Client Secret as well as the auto-discovery URL.&lt;/li&gt;
&lt;li&gt;Use at least &lt;code&gt;openid profile email&lt;/code&gt; as the scope, and expand as necessary.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Once the authentication source is created and enabled, users can start
signing in via OIDC.  If a user has already signed in before using
their LDAP password, they will be prompted for said password in order
to "link" their OIDC user to the existing LDAP user.  Whether users
actually do this or not does not affect the migration.&lt;/p&gt;
&lt;h2 id="migration-of-users-from-ldap-to-oidc"&gt;Migration of Users From LDAP to OIDC&lt;/h2&gt;
&lt;p&gt;To understand the next migration steps, we first need to get an
overview of the different types of names used to identify users in
Forgejo:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;username&lt;/strong&gt; is the name users are known as within Forgejo.  You
  can consider this name to be a primary key (though not exactly true
  at the database schema level).&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;display name&lt;/strong&gt; is the name shown in the web UI.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;authentication sign-in name&lt;/strong&gt; is the name provided by the authentication source.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This decoupling of the username and authentication name simplifies the
migration a lot.  With an LDAP authentication source, the
authentication name is configurable - usually you'd choose an LDAP
attribute such as &lt;code&gt;uid&lt;/code&gt; or &lt;code&gt;sAMAccoutName&lt;/code&gt; or (if you are really
concerned about all-time uniqueness) &lt;code&gt;objectGUID&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;For the OIDC authentication source, the authentication name is not
configurable - Forgejo insists on using the OIDC Subject Identifier
(the &lt;code&gt;sub&lt;/code&gt; key in the &lt;code&gt;id_token&lt;/code&gt;).  Keycloak generates an UUID for the
subject identifier.  Fortunately, Forgejo is smart enough to also
consider the &lt;code&gt;preferred_username&lt;/code&gt; token claim, and maps it to the
Forgejo username - and prompts an already existing user for linking
the account, as mentioned above.&lt;/p&gt;
&lt;p&gt;However, since we wanted to disable LDAP authentication, this account
linking would not have worked, so we needed to change the mapping for
all users.  For this, you need to obtain the subject ids and usernames
of all users in Keycloak - this is most easily done by asking
Keycloak's database (using PostgreSQL in this case):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="gp"&gt;$ &lt;/span&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;-u&lt;span class="w"&gt; &lt;/span&gt;postgres&lt;span class="w"&gt; &lt;/span&gt;psql&lt;span class="w"&gt; &lt;/span&gt;keycloak
&lt;span class="go"&gt;keycloak=# SELECT id, username FROM user_entity;&lt;/span&gt;
&lt;span class="go"&gt;                  id                  | username&lt;/span&gt;
&lt;span class="go"&gt;--------------------------------------+----------&lt;/span&gt;
&lt;span class="go"&gt; 7ac0e93b-8725-4efa-95dc-0e20c6dbfa0a | s3lph&lt;/span&gt;
&lt;span class="go"&gt; 8933480e-146f-4e7c-ac9c-9c8adfd69069 | s4lph&lt;/span&gt;
&lt;span class="go"&gt; d398d274-7d9a-45b3-96e7-96b8811afa72 | s5lph&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;If there's only a small number of users in your Forgejo instance, you
could now edit every single one in the admin settings, and change its
authentication source to the OIDC provider and the login name to this
subject identifier.  The more scalable approach, however, would be to
transform this list into a series of UPDATE statements for Forgejo's
database (in our case, MariaDB is used here):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;user&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;login_name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;7ac0e93b-8725-4efa-95dc-0e20c6dbfa0a&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;login_type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;login_source&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;where&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;lower_name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;s3lph&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;user&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;login_name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;8933480e-146f-4e7c-ac9c-9c8adfd69069&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;login_type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;login_source&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;where&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;lower_name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;s4lph&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;user&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;login_name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;d398d274-7d9a-45b3-96e7-96b8811afa72&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;login_type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;login_source&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;where&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;lower_name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;s5lph&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Note the additional columns &lt;code&gt;login_type&lt;/code&gt; and &lt;code&gt;login_source&lt;/code&gt; we need to modify:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;login_type&lt;/code&gt; is the type of login source being used.  This is &lt;code&gt;6&lt;/code&gt;
  for all OAuth sources.  The list of type identifiers can be found in
  the &lt;a href="https://codeberg.org/forgejo/forgejo/src/branch/forgejo/models/auth/source.go#L24-L33"&gt;Forgejo source code&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;login_source&lt;/code&gt; is the numeric id of the OIDC login source you
  created in Forgejo.  You can see this ID in the list of
  authentication sources in the Forgejo admin settings.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Finally, if you configured Forgejo to import SSH public keys from
LDAP, you need to remove the mapping from SSH keys to the login source
they came from.  Otherwise, users with such keys won't be able to open
their SSH key settings anymore:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;public_key&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;login_source_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;login_source_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Replace the login source ID in the &lt;code&gt;WHERE&lt;/code&gt; clause with the ID of your
LDAP authentiation source (again, to be found in the Forgejo admin
settings).&lt;/p&gt;
&lt;p&gt;This concludes the OIDC migration of Forgejo, and the LDAP
authentication source can now be safely disabled or removed.&lt;/p&gt;
&lt;h2 id="tips-and-tricks"&gt;Tips and Tricks&lt;/h2&gt;
&lt;p&gt;If user self-registration is disabled on your Forgejo
instance, you should add the following to your &lt;code&gt;app.ini&lt;/code&gt; so that new
OIDC users can sign into Forgejo without admin intervention:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="k"&gt;[oauth2_client]&lt;/span&gt;
&lt;span class="na"&gt;ENABLE_AUTO_REGISTRATION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Forgejo does not automatically redirect to the IdP for login - users
always have to click the "Login with OpenID" button.  If you want your
users to be redirected to the SSO automatically when they try to log
in, you have to configure this in your reverse proxy.  For example, in
Apache 2:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nb"&gt;RewriteEngine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;on&lt;/span&gt;
&lt;span class="nb"&gt;RewriteCond&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;%{QUERY_STRING}&lt;span class="w"&gt; &lt;/span&gt;!local=1
&lt;span class="nb"&gt;RewriteCond&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;%{REQUEST_METHOD}&lt;span class="w"&gt; &lt;/span&gt;GET
&lt;span class="nb"&gt;RewriteRule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;^/user/login$&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="sx"&gt;/user/oauth2/oidc&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;[L,R,QSD]
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Replace the last URL component (&lt;code&gt;/oidc&lt;/code&gt;) with the name you gave to
your OIDC authentication source in Forgejo.  The &lt;code&gt;RewriteCond&lt;/code&gt;s in
this config allow you to append &lt;code&gt;?local=1&lt;/code&gt; to disable the redirect,
e.g. if you need to sign in as a local admin user.&lt;/p&gt;</content><category term="hacks"/><category term="Migration"/><category term="OIDC"/><category term="Forgejo"/><category term="Gitea"/></entry><entry><title>How much ActivityPub can a Static Site Generator implement?</title><link href="https://s3lph.me/activitypub-static-site.html" rel="alternate"/><published>2022-11-18T01:00:00+01:00</published><updated>2022-11-18T01:00:00+01:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2022-11-18:/activitypub-static-site.html</id><summary type="html">&lt;p&gt;There already are multiple blogging solutions which are part of the
&lt;a href="https://en.wikipedia.org/wiki/Fediverse"&gt;Fediverse&lt;/a&gt;.  Among them are dedicated Fediverse blogs such
as &lt;a href="https://joinplu.me/"&gt;Plume&lt;/a&gt; or &lt;a href="https://writefreely.org/"&gt;WriteFreely&lt;/a&gt;, but there also are
plugins which retrofit existing CMS, e.g. &lt;a href="https://wordpress.org/plugins/activitypub/"&gt;for Wordpress&lt;/a&gt; or
&lt;a href="https://www.drupal.org/project/activitypub"&gt;Drupal&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://activitypub.rocks/"&gt;ActivityPub&lt;/a&gt;, the network protocol behind the Fediverse can only
be fully implemented …&lt;/p&gt;</summary><content type="html">&lt;p&gt;There already are multiple blogging solutions which are part of the
&lt;a href="https://en.wikipedia.org/wiki/Fediverse"&gt;Fediverse&lt;/a&gt;.  Among them are dedicated Fediverse blogs such
as &lt;a href="https://joinplu.me/"&gt;Plume&lt;/a&gt; or &lt;a href="https://writefreely.org/"&gt;WriteFreely&lt;/a&gt;, but there also are
plugins which retrofit existing CMS, e.g. &lt;a href="https://wordpress.org/plugins/activitypub/"&gt;for Wordpress&lt;/a&gt; or
&lt;a href="https://www.drupal.org/project/activitypub"&gt;Drupal&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://activitypub.rocks/"&gt;ActivityPub&lt;/a&gt;, the network protocol behind the Fediverse can only
be fully implemented by means of an active server component: Among
other things, incoming messages delivered to inboxes have to be
processed.  Sometimes they need to be forwarded, and outgoing messages
need to be signed.&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2022-11-17-activitypub.png"&gt;
    &lt;img src="https://s3lph.me/images/2022-11-17-activitypub.png"
         title="The basic mechanisms behind ActivityPub: Users publish resources in their outbox, and retrieve received resources from their inbox."
         alt="The basic mechanisms behind ActivityPub: Users publish resources in their outbox, and retrieve received resources from their inbox."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure1" class="figureid"&gt;Figure 1:&lt;/a&gt;
    The basic mechanisms behind ActivityPub: Users publish resources in their outbox, and retrieve received resources from their inbox.
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Nevertheless I wanted to figure out, which parts of the ActivityPub
protocol can be implemented in a purely static website, and how well
other servers in the Fediverse interact with it.  My goal was to attach
this blog to the Fediverse.  The blog is generated using the &lt;em&gt;static
site generator&lt;/em&gt; software &lt;a href="https://getpelican.com/"&gt;Pelican&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="metadata-endpoints"&gt;Metadata Endpoints&lt;/h2&gt;
&lt;p&gt;One prerequisite for implementing ActivityPub are multiple static
metadata endpoints, with which a server signals &lt;strong&gt;whether&lt;/strong&gt; it
supports ActivityPub, and under &lt;strong&gt;which HTTP endpoints&lt;/strong&gt; the various
ActivityPub resources are reachable:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;/.well-known/nodeinfo&lt;/code&gt; simply links to the "real" nodeinfo endpoint:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;links&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;href&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/nodeinfo&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;rel&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;http://nodeinfo.diaspora.software/ns/schema/2.0&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Behind the linked path (which can be chosen freely), the global
metadata of this ActivityPub instance is published:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;version&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;2.0&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;software&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;pelican-activitypub&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;version&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;0.1&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;protocols&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;activitypub&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;services&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;inbound&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;outbound&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;atom1.0&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;rss2.0&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;openRegistrations&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;usage&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;users&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;total&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;localPosts&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;27&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;metadata&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;nodeName&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;s3lph made&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;This JSON document describes the server, however we still need to
discover the individual users of this instance.  This is where the
webfinger endpoint comes in.  Usually it is found at
&lt;code&gt;/.well-known/webfinger&lt;/code&gt;, but some pieces of software insist on first
resolving this path using the &lt;code&gt;/.well-known/host-meta&lt;/code&gt; endpoint:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?xml version=&amp;quot;1.0&amp;quot; encoding=&amp;quot;UTF-8&amp;quot;?&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;XRD&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;xmlns=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;http://docs.oasis-open.org/ns/xri/xrd-1.0&amp;quot;&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;Link&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;lrdd&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;template=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;https://s3lph.me/.well-known/webfinger?resource={uri}&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;application/xrd+xml&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/XRD&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Now we already stumbled over the fist obstacle preventing a purely
static implementation: The name of the user to be resolved is passed
as an URL parameter, which needs to be handled by a HTTP server.&lt;/p&gt;
&lt;p&gt;There are two possible workarounds:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;If there only is a single user on the server, the parameter can be
  ignored.  Instead, always the same static response is returned, no
  matter the URL parameter.&lt;/li&gt;
&lt;li&gt;If there are multiple users, we can generate a static webfinger
  endpoint per user.  This requires the webserver to be configured to
  redirect the regular webfinger endpoint to these user specific
  endpoints.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For example, using the Apache webserver, this can be achieved like this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="n"&gt;RewriteEngine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;on&lt;/span&gt;
&lt;span class="n"&gt;RewriteRule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;^/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;well&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;known&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;webfinger&lt;/span&gt;&lt;span class="vm"&gt;?&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nl"&gt;acct&lt;/span&gt;&lt;span class="p"&gt;:(&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="n"&gt;^@&lt;/span&gt;&lt;span class="o"&gt;]+&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nv"&gt;@s3lph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;me&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;well&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;known&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;_webfinger&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="n"&gt;L&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;The webfinger endpoint links to the actual user resources:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;subject&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;acct:s3lph@s3lph.me&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;aliases&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/author/s3lph.html&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/users/s3lph&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;links&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;rel&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;http://webfinger.net/rel/profile-page&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;text/html&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;href&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/author/s3lph.html&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;rel&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;self&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;application/activity+json&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;href&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/users/s3lph&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;In this case, the author &lt;code&gt;@s3lph@s3lph.me&lt;/code&gt; is resolved to two alias
URLs: The author's feed in the blog, as well as the ActivityPub
&lt;code&gt;Person&lt;/code&gt; resource.&lt;/p&gt;
&lt;h2 id="activitypub-persons-and-articles"&gt;ActivityPub: Persons and Articles&lt;/h2&gt;
&lt;p&gt;Now that we can resolve usernames such as &lt;code&gt;@s3lph@s3lph.me&lt;/code&gt; to
ActivityPub URLs such as &lt;code&gt;https://s3lph.me/activitypub/users/s3lph&lt;/code&gt;,
we can take a closer look at the &lt;code&gt;Person&lt;/code&gt; resource behind this URL:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@context&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://www.w3.org/ns/activitystreams&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;schema&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;http://schema.org#&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;toot&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;http://joinmastodon.org/ns#&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;PropertyValue&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;schema:PropertyValue&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;value&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;schema:value&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;alsoKnownAs&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@id&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;as:alsoKnownAs&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;@id&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;movedTo&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@id&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;as:movedTo&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;@id&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;discoverable&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;toot:discoverable&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Person&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;id&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/users/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;preferredUsername&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;url&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/author/s3lph.html&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;s3lph made&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;summary&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;This is an EXPERIMENTAL implementation for a read-only ActivityPub feed of my blog.&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;icon&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Image&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;mediaType&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;image/png&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;url&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/favicon.ico&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;image&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;tag&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;attachment&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;PropertyValue&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Web&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;value&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&amp;lt;a href=\&amp;quot;https://s3lph.me\&amp;quot;&amp;gt;s3lph.me&amp;lt;/a&amp;gt;&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;PropertyValue&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Mastodon&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;value&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&amp;lt;a rel=\&amp;quot;me\&amp;quot; href=\&amp;quot;https://chaos.social/@s3lph\&amp;quot;&amp;gt;@s3lph@chaos.social&amp;lt;/a&amp;gt;&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;PropertyValue&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Matrix&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;value&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&amp;lt;a rel=\&amp;quot;me\&amp;quot; href=\&amp;quot;https://mto.kabelsalat.ch/#/@s3lph:kabelsalat.ch\&amp;quot;&amp;gt;@s3lph:kabelsalat.ch&amp;lt;/a&amp;gt;&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;movedTo&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://chaos.social/users/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;alsoKnownAs&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://chaos.social/users/s3lph&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;inbox&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/collections/inbox/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;outbox&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/collections/outbox/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;following&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/collections/following/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;followers&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/collections/followers/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;discoverable&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;manuallyApprovesFollowers&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;published&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;2020-02-05T01:36:00+01:00&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;updated&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;2022-11-12T14:05:23Z&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;endpoints&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;sharedInbox&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/collections/inbox/s3lph&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;This resource is a bit bigger than before, so let's look at it piece
by piece.  At first the schema of this JSON document is described:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@context&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://www.w3.org/ns/activitystreams&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;schema&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;http://schema.org#&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;toot&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;http://joinmastodon.org/ns#&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;PropertyValue&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;schema:PropertyValue&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;value&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;schema:value&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;alsoKnownAs&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@id&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;as:alsoKnownAs&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;@id&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;movedTo&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@id&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;as:movedTo&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;@id&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;discoverable&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;toot:discoverable&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Next the basic information about the person are described.  These are
used by ActivityPub-Clients to display the user's profile page:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Person&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;id&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/users/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;preferredUsername&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;url&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/author/s3lph.html&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;s3lph made&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;summary&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;This is an EXPERIMENTAL implementation for a read-only ActivityPub feed of my blog...&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;icon&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Image&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;mediaType&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;image/png&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;url&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/favicon.ico&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;image&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Some additional metadata can be provided:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;tag&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;attachment&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;PropertyValue&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Web&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;value&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&amp;lt;a href=\&amp;quot;https://s3lph.me\&amp;quot;&amp;gt;s3lph.me&amp;lt;/a&amp;gt;&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;PropertyValue&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Mastodon&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;value&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&amp;lt;a rel=\&amp;quot;me\&amp;quot; href=\&amp;quot;https://chaos.social/@s3lph\&amp;quot;&amp;gt;@s3lph@chaos.social&amp;lt;/a&amp;gt;&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;PropertyValue&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Matrix&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;value&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&amp;lt;a rel=\&amp;quot;me\&amp;quot; href=\&amp;quot;https://mto.kabelsalat.ch/#/@s3lph:kabelsalat.ch\&amp;quot;&amp;gt;@s3lph:kabelsalat.ch&amp;lt;/a&amp;gt;&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;movedTo&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://chaos.social/users/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;alsoKnownAs&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://chaos.social/users/s3lph&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;"PropertyValue" attachments are rendered as tables on the user's
profile page by most ActivityPub clients.  &lt;code&gt;tags&lt;/code&gt; list attributes such
as hashtags and mentions of other users within the profile's
description, so that they can be easily indexed by other servers in
the Fediverse.&lt;/p&gt;
&lt;p&gt;movedTo and alsoKnownAs are used to indicate that the account has
moved, and the new account should be followed instead.&lt;/p&gt;
&lt;p&gt;With all of this, we get a user profile that can be viewed on
Fediverse client applications, such as the Mastodon app "Tusky":&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2022-11-17-activitypub-profile.png"&gt;
    &lt;img src="https://s3lph.me/images/2022-11-17-activitypub-profile.png"
         title="The profile of @s3lph@s3lph.me, as shown in the Mastodon app Tusky."
         alt="The profile of @s3lph@s3lph.me, as shown in the Mastodon app Tusky."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure2" class="figureid"&gt;Figure 2:&lt;/a&gt;
    The profile of @s3lph@s3lph.me, as shown in the Mastodon app Tusky
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Finally, the URLs to linked ActivityPub resources have to be provided:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;inbox&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/collections/inbox/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;outbox&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/collections/outbox/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;following&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/collections/following/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;followers&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/collections/followers/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;I've already mentioned the relevance of the inbox and outbox
before. Since this implementation does not process inboxes, there only
is an empty colllection (list of ActivityPub resources) behind the
inbox URL.  The same goes for the "following" and "followers"
collections:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@context&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://www.w3.org/ns/activitystreams&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;OrderedCollection&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;id&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/collections/inbox/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;totalItems&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;orderedItems&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;The outbox is a collection as well, containing the articles published
by this user.  One of these articles (actually this one) looks like
this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@context&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://www.w3.org/ns/activitystreams&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Article&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;id&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/posts/activitypub-static-site&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;published&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;2022-11-17T02:00:00+01:00&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;inReplyTo&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;url&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub-static-site-de.html&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;attributedTo&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/users/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;to&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://www.w3.org/ns/activitystreams#Public&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;cc&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/collections/followers/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://chaos.social/users/s3lph&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;How much ActivityPub can a Static Site Generator implement?&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;nameMap&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;en&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;How much ActivityPub can a Static Site Generator implement?&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;de&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Wie viel ActivityPub kann ein Static Site Generator?&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;content&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&amp;lt;p&amp;gt;There already are multiple blogging solutions...&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;contentMap&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;en&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&amp;lt;p&amp;gt;There already are multiple blogging solutions...&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;de&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&amp;lt;p&amp;gt;Es gibt bereits verschiedene Blog-Lösungen...&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;summary&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;attachment&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;tag&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Hashtag&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;#activitypub&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;href&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/tags/activitypub&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Hashtag&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;#pelican&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;href&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/tags/pelican&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Mention&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;href&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://chaos.social/users/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;@s3lph@chaos.social&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;The beginning of an article is more or less equal to that of a person,
so I won't be repeating it here.&lt;/p&gt;
&lt;p&gt;Next the relationships to other resources are described, e.g. who
wrote the article, and to whom it is addressed.  The special URL
&lt;code&gt;https://www.w3.org/ns/activitystreams#Public&lt;/code&gt; describes that the
article is public, and should e.g. be listed in global timelines:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;inReplyTo&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;url&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub-static-site-de.html&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;attributedTo&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/users/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;to&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://www.w3.org/ns/activitystreams#Public&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;cc&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/collections/followers/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://chaos.social/users/s3lph&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Title and content of the article can be multilingual.  However, the
&lt;code&gt;contentMap&lt;/code&gt; und &lt;code&gt;nameMap&lt;/code&gt; attributes are only supported by a few
ActivityPub servers or clients.  Most others simply always show the
untranslated default content:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;How much ActivityPub can a Static Site Generator implement?&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;nameMap&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;en&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;How much ActivityPub can a Static Site Generator implement?&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;de&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Wie viel ActivityPub kann ein Static Site Generator?&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;content&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&amp;lt;p&amp;gt;There already are multiple blogging solutions...&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;contentMap&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;en&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&amp;lt;p&amp;gt;There already are multiple blogging solutions...&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;de&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&amp;lt;p&amp;gt;Es gibt bereits verschiedene Blog-Lösungen...&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;summary&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Finally, same as with persons, additional data like tags and mentions
of other users are listed in a machine readable form:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;attachment&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;tag&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Hashtag&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;#activitypub&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;href&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/tags/activitypub&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Hashtag&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;#pelican&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;href&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/tags/pelican&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Mention&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;href&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://chaos.social/users/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;@s3lph@chaos.social&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Sow now we have modelled authors and their articles as ActivityPub
resources.  This is also everything which can be reasonably
implemented as a purely static site.&lt;/p&gt;
&lt;p&gt;The code for generating these ActivityPub resources is available as a
&lt;a href="https://git.kabelsalat.ch/s3lph/pelican-plugin-activitypub"&gt;Pelican plugin&lt;/a&gt;.  However, before you go ahead and
install this on your own Pelican blog, I'd advise you to finish
reading this article.&lt;/p&gt;
&lt;h2 id="compatibility-to-fediverse-services"&gt;Compatibility to Fediverse Services&lt;/h2&gt;
&lt;p&gt;For testing my implementation, I set up three different Fediverse
instances to interact with my blog: &lt;a href="https://joinmastodon.org/"&gt;Mastodon&lt;/a&gt;,
&lt;a href="https://pleroma.social/"&gt;Pleroma&lt;/a&gt; and &lt;a href="https://join.misskey.page/"&gt;Misskey&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;All three services are able to retrieve and show the profile
&lt;code&gt;@s3lph@s3lph.me&lt;/code&gt;, and the number of blog articles is shown.  However,
the articles themselves are not shown.  As it turns out, articles from
other instances are usually not retrieved automatically.&lt;/p&gt;
&lt;p&gt;The articles would only be shown if they were POSTed from their origin
instance to the target instance.  Alternatively, all three test
instances were able to show the articles by pasting the article's URL
into the search bar.  Afterwards these articles were also shown in the
timeline of their author.&lt;/p&gt;
&lt;p&gt;Unfortunately, this is almost everything that can be achieved with a
purely static implementation.  Especially the following important
functions are not available:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Following&lt;/strong&gt;: If Alice wants to follow the Fediverse account of Bob,
  she has to send a &lt;em&gt;follow request&lt;/em&gt; to Bob.  Bob (or his instance)
  has to confirm this request before it becomes effective.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Replies&lt;/strong&gt;: If Alice wants to reply to a message of Bob, her
  response is only visible on Alice's instance at first.  The response
  will also be posted to the inbox of Bob, and it would be in his
  instance's responsibility to forward the reply to all other involved
  instances.  This crucial step is not possible with a static
  ActivityPub endpoint.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Deletion&lt;/strong&gt;: If an article ha been cached by another instance, it
  is usually kept in the cache until it's explicitly deleted by its
  origin instance.  For this, a cryptographically signed deletion
  request would have to be submitted to the instance's inbox.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Updates&lt;/strong&gt;: The same limitations that apply to deletions apply to
  updates as well.  In fact, this list item will never show up on
  instances that already have an earlier version of this article in
  their cache.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What does work are likes and boosts, tough they do not increment the
counters under an article.&lt;/p&gt;
&lt;p&gt;Additionally, it turns out that e.g. Mastodon only shows a link
preview to the article, since the &lt;code&gt;Article&lt;/code&gt; object type is not fully
supported; the &lt;code&gt;Note&lt;/code&gt; type is used for short messages.  However, both
Pleroma and Misskey show the full article natively.&lt;/p&gt;
&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;The question "How much ActivityPub can a Static Site Generator
implement?" can thus be best answered as "quite a lot, but not enough
to be relevant in practice."  Even for this blog, the purely static
ActivityPub implementation will most likely not have any relevance,
however the articles will presumably keep being published on the
Fediverse in this limited form.&lt;/p&gt;
&lt;p&gt;PS: If you're reading this article on the Fediverse and want to write
a reply, please (additionally) address it to &lt;code&gt;@s3lph@chaos.social&lt;/code&gt;,
otherwise I won't see it.&lt;/p&gt;</content><category term="hacks"/><category term="ActivityPub"/><category term="Pelican"/></entry><entry><title>Wie viel ActivityPub kann ein Static Site Generator?</title><link href="https://s3lph.me/activitypub-static-site-de.html" rel="alternate"/><published>2022-11-18T01:00:00+01:00</published><updated>2022-11-18T01:00:00+01:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2022-11-18:/activitypub-static-site-de.html</id><summary type="html">&lt;p&gt;Es gibt bereits verschiedene Blog-Lösungen, die Teil des
&lt;a href="https://en.wikipedia.org/wiki/Fediverse"&gt;Fediverse&lt;/a&gt; sind: Darunter sind sowohl dedizierte
Fediverse-Blogs wie &lt;a href="https://joinplu.me/"&gt;Plume&lt;/a&gt; und &lt;a href="https://writefreely.org/"&gt;WriteFreely&lt;/a&gt;,
aber es gibt auch Plugins, die bestehende CMS nachrüsten,
z.B. &lt;a href="https://wordpress.org/plugins/activitypub/"&gt;für Wordpress&lt;/a&gt; oder &lt;a href="https://www.drupal.org/project/activitypub"&gt;für Drupal&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://activitypub.rocks/"&gt;ActivityPub&lt;/a&gt;, das Netzwerk-Protokoll hinter dem Fediverse, lässt
sich nur mit einer aktiven Serverkomponente vollständig umsetzen …&lt;/p&gt;</summary><content type="html">&lt;p&gt;Es gibt bereits verschiedene Blog-Lösungen, die Teil des
&lt;a href="https://en.wikipedia.org/wiki/Fediverse"&gt;Fediverse&lt;/a&gt; sind: Darunter sind sowohl dedizierte
Fediverse-Blogs wie &lt;a href="https://joinplu.me/"&gt;Plume&lt;/a&gt; und &lt;a href="https://writefreely.org/"&gt;WriteFreely&lt;/a&gt;,
aber es gibt auch Plugins, die bestehende CMS nachrüsten,
z.B. &lt;a href="https://wordpress.org/plugins/activitypub/"&gt;für Wordpress&lt;/a&gt; oder &lt;a href="https://www.drupal.org/project/activitypub"&gt;für Drupal&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://activitypub.rocks/"&gt;ActivityPub&lt;/a&gt;, das Netzwerk-Protokoll hinter dem Fediverse, lässt
sich nur mit einer aktiven Serverkomponente vollständig umsetzen:
Unter anderem muss auf eingehende Nachrichten in den Inboxen reagiert
werden, teilweise müssen diese auch an andere Server weitergeleitet
werden, und ausgehende Nachrichten müssen zeitnah signiert werden.&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2022-11-17-activitypub.png"&gt;
    &lt;img src="https://s3lph.me/images/2022-11-17-activitypub.png"
         title="Die grundsätzlichen Mechanismen hinter ActivityPub: User veröffentlichen Ressourcen in ihrer eigenen Outbox, und rufen empfangene Ressourcen aus ihrer Inbox ab."
         alt="Die grundsätzlichen Mechanismen hinter ActivityPub: User veröffentlichen Ressourcen in ihrer eigenen Outbox, und rufen empfangene Ressourcen aus ihrer Inbox ab."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure1" class="figureid"&gt;Abbildung 1:&lt;/a&gt;
    Die grundsätzlichen Mechanismen hinter ActivityPub: User veröffentlichen Ressourcen in ihrer eigenen Outbox, und rufen empfangene Ressourcen aus ihrer Inbox ab.
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Trotzdem wollte ich herausfinden, welche Teile des
ActivityPub-Protokolls mit einer rein statischen Website implementiert
werden können, und wie gut andere Server im Fediverse damit umgehen
können.  Mein Ziel war, meinen Blog hier ans Fediverse zu hängen.  Der
Blog wird mit der &lt;em&gt;Static Site Generator&lt;/em&gt;-Software &lt;a href="https://getpelican.com/"&gt;Pelican&lt;/a&gt;
erzeugt.&lt;/p&gt;
&lt;h2 id="metadaten-endpunkte"&gt;Metadaten-Endpunkte&lt;/h2&gt;
&lt;p&gt;Eine Grundvoraussetzung, um ActivityPub zu implementieren, sind
diverse statische Metadaten-Endpunkte, mit denen der Server
signalisiert, &lt;strong&gt;dass&lt;/strong&gt; er ActivityPub unterstützt, und unter &lt;strong&gt;welchen
HTTP-Endpunkten&lt;/strong&gt; die verschiedenen ActivityPub-Ressourcen zu finden
sind:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;/.well-known/nodeinfo&lt;/code&gt; verlinkt einfach nur auf den "echten" nodeinfo-Endpunkt:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;links&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;href&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/nodeinfo&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;rel&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;http://nodeinfo.diaspora.software/ns/schema/2.0&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Unter dem verlinkten Pfad (der frei gewählt werden kann), werden die
globalen Metadaten der ActivityPub-Instanz publiziert:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;version&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;2.0&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;software&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;pelican-activitypub&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;version&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;0.1&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;protocols&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;activitypub&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;services&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;inbound&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;outbound&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;atom1.0&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;rss2.0&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;openRegistrations&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;usage&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;users&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;total&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;localPosts&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;27&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;metadata&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;nodeName&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;s3lph made&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Mit diesem JSON-Dokument wird der Server an sich beschrieben, nun
müssen aber noch die einzelnen User der Instanz gefunden werden.
Hierzu dient der webfinger-Endpunkt.  Dieser ist zwar üblicherweise
unter &lt;code&gt;/.well-known/webfinger&lt;/code&gt; zu finden, aber einzelne
Softwarelösungen bestehen trotzdem darauf, den Pfad erst unter einem
anderen Endpunkt nachzuschlagen, und zwar unter
&lt;code&gt;/.well-known/host-meta&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?xml version=&amp;quot;1.0&amp;quot; encoding=&amp;quot;UTF-8&amp;quot;?&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;XRD&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;xmlns=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;http://docs.oasis-open.org/ns/xri/xrd-1.0&amp;quot;&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;Link&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;lrdd&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;template=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;https://s3lph.me/.well-known/webfinger?resource={uri}&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;application/xrd+xml&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/XRD&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Hier sehen wir schon das erste Hindernis, das eine rein statische
Implementation verhindert: Der Name des aufzulösenden Users wird als
URL-Parameter übergeben, der durch einen HTTP-Server behandelt werden
muss.&lt;/p&gt;
&lt;p&gt;Hierzu gibt es zwei mögliche Lösungen:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Wenn es auf dem Server nur einen einzelnen User gibt, kann der
  Parameter eigentlich ignoriert werden.  Stattdessen wird einfach
  immer die gleiche, statische Antwort zurückgegeben.&lt;/li&gt;
&lt;li&gt;Wenn es mehrere User gibt, kann für jeden User ein statischer
  webfinger-Endpunkt generiert werden.  Dies setzt aber voraus, dass
  der Webserver konfiguriert wird, den eigentlichen webfinger-Endpunkt
  entsprechend umgeleitet werd.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Dies lässt sich z.B. im Apache-Webserver so umsetzen:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="n"&gt;RewriteEngine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;on&lt;/span&gt;
&lt;span class="n"&gt;RewriteRule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;^/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;well&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;known&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;webfinger&lt;/span&gt;&lt;span class="vm"&gt;?&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nl"&gt;acct&lt;/span&gt;&lt;span class="p"&gt;:(&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="n"&gt;^@&lt;/span&gt;&lt;span class="o"&gt;]+&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nv"&gt;@s3lph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;me&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;well&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;known&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;_webfinger&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="n"&gt;L&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Der webfinger-Endpunkt verlinkt nun weiter auf die tatsächlichen User-Ressourcen:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;subject&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;acct:s3lph@s3lph.me&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;aliases&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/author/s3lph.html&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/users/s3lph&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;links&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;rel&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;http://webfinger.net/rel/profile-page&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;text/html&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;href&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/author/s3lph.html&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;rel&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;self&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;application/activity+json&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;href&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/users/s3lph&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;In diesem Fall wird der Autor, in diesem Fall &lt;code&gt;@s3lph@s3lph.me&lt;/code&gt; auf
zwei Alias-URLs aufgelöst: Der Autoren-Feed im Blog, sowie die
&lt;code&gt;Person&lt;/code&gt; ActivityPub-Ressource.&lt;/p&gt;
&lt;h2 id="activitypub-personen-und-artikel"&gt;ActivityPub: Personen und Artikel&lt;/h2&gt;
&lt;p&gt;Jetzt, wo wir einen Username wie &lt;code&gt;@s3lph@s3lph.me&lt;/code&gt; zu einer
ActivityPub-URL wie &lt;code&gt;https://s3lph.me/activitypub/users/s3lph&lt;/code&gt;
auflösen können, können wir die Personen-Ressource unter dieser URL
genauer anschauen:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@context&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://www.w3.org/ns/activitystreams&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;schema&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;http://schema.org#&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;toot&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;http://joinmastodon.org/ns#&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;PropertyValue&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;schema:PropertyValue&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;value&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;schema:value&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;alsoKnownAs&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@id&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;as:alsoKnownAs&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;@id&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;movedTo&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@id&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;as:movedTo&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;@id&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;discoverable&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;toot:discoverable&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Person&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;id&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/users/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;preferredUsername&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;url&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/author/s3lph.html&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;s3lph made&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;summary&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;This is an EXPERIMENTAL implementation for a read-only ActivityPub feed of my blog.&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;icon&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Image&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;mediaType&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;image/png&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;url&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/favicon.ico&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;image&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;tag&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;attachment&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;PropertyValue&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Web&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;value&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&amp;lt;a href=\&amp;quot;https://s3lph.me\&amp;quot;&amp;gt;s3lph.me&amp;lt;/a&amp;gt;&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;PropertyValue&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Mastodon&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;value&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&amp;lt;a rel=\&amp;quot;me\&amp;quot; href=\&amp;quot;https://chaos.social/@s3lph\&amp;quot;&amp;gt;@s3lph@chaos.social&amp;lt;/a&amp;gt;&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;PropertyValue&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Matrix&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;value&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&amp;lt;a rel=\&amp;quot;me\&amp;quot; href=\&amp;quot;https://mto.kabelsalat.ch/#/@s3lph:kabelsalat.ch\&amp;quot;&amp;gt;@s3lph:kabelsalat.ch&amp;lt;/a&amp;gt;&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;movedTo&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://chaos.social/users/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;alsoKnownAs&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://chaos.social/users/s3lph&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;inbox&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/collections/inbox/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;outbox&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/collections/outbox/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;following&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/collections/following/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;followers&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/collections/followers/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;discoverable&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;manuallyApprovesFollowers&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;published&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;2020-02-05T01:36:00+01:00&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;updated&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;2022-11-12T14:05:23Z&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;endpoints&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;sharedInbox&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/collections/inbox/s3lph&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Diese Ressource ist schon ein gutes Stück grösser, daher schauen wir
das am besten Stück für Stück an.  Zuerst wird das Schema des
JSON-Dokuments beschrieben:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@context&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://www.w3.org/ns/activitystreams&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;schema&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;http://schema.org#&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;toot&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;http://joinmastodon.org/ns#&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;PropertyValue&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;schema:PropertyValue&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;value&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;schema:value&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;alsoKnownAs&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@id&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;as:alsoKnownAs&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;@id&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;movedTo&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@id&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;as:movedTo&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;@id&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;discoverable&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;toot:discoverable&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Als nächstes werden die grundlegenden Informationen über die Person
beschrieben.  Diese werden von ActivityPub-Clients benutzt, um die
User-Seite darzustellen:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Person&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;id&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/users/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;preferredUsername&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;url&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/author/s3lph.html&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;s3lph made&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;summary&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;This is an EXPERIMENTAL implementation for a read-only ActivityPub feed of my blog...&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;icon&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Image&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;mediaType&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;image/png&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;url&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/favicon.ico&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;image&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Zusätzlich können noch einige weitere Metadaten angegeben werden:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;tag&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;attachment&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;PropertyValue&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Web&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;value&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&amp;lt;a href=\&amp;quot;https://s3lph.me\&amp;quot;&amp;gt;s3lph.me&amp;lt;/a&amp;gt;&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;PropertyValue&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Mastodon&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;value&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&amp;lt;a rel=\&amp;quot;me\&amp;quot; href=\&amp;quot;https://chaos.social/@s3lph\&amp;quot;&amp;gt;@s3lph@chaos.social&amp;lt;/a&amp;gt;&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;PropertyValue&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Matrix&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;value&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&amp;lt;a rel=\&amp;quot;me\&amp;quot; href=\&amp;quot;https://mto.kabelsalat.ch/#/@s3lph:kabelsalat.ch\&amp;quot;&amp;gt;@s3lph:kabelsalat.ch&amp;lt;/a&amp;gt;&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;movedTo&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://chaos.social/users/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;alsoKnownAs&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://chaos.social/users/s3lph&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Attachments vom Typ "PropertyValue" werden z.B. von den meisten
ActivityPub-Clients als Tabelle im Profil des Users dargestellt.
Unter &lt;code&gt;tags&lt;/code&gt; werden z.B. Hashtags oder Erwähnungen von anderen Usern
im Profil des Users aufgelistet, damit diese von anderen Servern im
Fediverse indexiert werden.&lt;/p&gt;
&lt;p&gt;Mit movedTo und alsoKnownAs wird angegeben, dass der Account umgezogen
ist, und andere User dem neuen Profil folgen sollen.&lt;/p&gt;
&lt;p&gt;Damit kommt ein Profil zustande, das mit Fediverse-Clients aufgerufen
werden kann. So zum Beispiel in der Mastodon-App Tusky:&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2022-11-17-activitypub-profile.png"&gt;
    &lt;img src="https://s3lph.me/images/2022-11-17-activitypub-profile.png"
         title="Das Profil von @s3lph@s3lph.me, dargestellt in der Mastodon-App Tusky."
         alt="Das Profil von @s3lph@s3lph.me, dargestellt in der Mastodon-App Tusky."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure2" class="figureid"&gt;Abbildung 2:&lt;/a&gt;
    Das Profil von @s3lph@s3lph.me, dargestellt in der Mastodon-App Tusky.
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Schlussendlich müssen noch die URLs zu den verknüpften
ActivityPub-Ressourcen angegeben werden:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;inbox&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/collections/inbox/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;outbox&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/collections/outbox/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;following&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/collections/following/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;followers&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/collections/followers/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Die Bedeutung von Inbox und Outbox wurden vorhin schon kurz erwähnt.
Da in dieser Implementation keine Inboxen verarbeitet werden, steckt
hinter der Inbox einfach nur eine leere "Collection" (Liste aus
ActivityPub-Ressourcen).  Das gleiche gilt für die "following"- und
"followers"-Collections:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@context&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://www.w3.org/ns/activitystreams&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;OrderedCollection&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;id&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/collections/inbox/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;totalItems&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;orderedItems&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Die Outbox ist ebenfalls eine Collection, die die von diesem User
veröffentlichten Artikel enthält.  Ein solcher (dieser) Artikel sieht
z.B. so aus:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@context&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://www.w3.org/ns/activitystreams&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Article&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;id&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/posts/activitypub-static-site&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;published&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;2022-11-17T02:00:00+01:00&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;inReplyTo&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;url&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub-static-site-de.html&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;attributedTo&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/users/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;to&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://www.w3.org/ns/activitystreams#Public&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;cc&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/collections/followers/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://chaos.social/users/s3lph&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;How much ActivityPub can a Static Site Generator implement?&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;nameMap&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;en&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;How much ActivityPub can a Static Site Generator implement?&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;de&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Wie viel ActivityPub kann ein Static Site Generator?&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;content&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&amp;lt;p&amp;gt;There already are multiple blogging solutions...&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;contentMap&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;en&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&amp;lt;p&amp;gt;There already are multiple blogging solutions...&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;de&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&amp;lt;p&amp;gt;Es gibt bereits verschiedene Blog-Lösungen...&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;summary&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;attachment&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;tag&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Hashtag&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;#activitypub&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;href&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/tags/activitypub&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Hashtag&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;#pelican&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;href&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/tags/pelican&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Mention&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;href&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://chaos.social/users/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;@s3lph@chaos.social&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Der Anfang eines Artikels sieht mehr oder weniger gleich aus wie bei
einer Person, daher wiederhole ich das hier nicht nochmal.&lt;/p&gt;
&lt;p&gt;Als nächstes wird die Beziehung zu anderen Ressourcen angegeben,
z.B. wer den Artikel verfasst hat, und an wen der Artikel adressiert
ist.  Die spezielle URL &lt;code&gt;https://www.w3.org/ns/activitystreams#Public&lt;/code&gt;
beschreibt, dass der Artikel öffentlich ist und z.B. in globalen
Timelines aufgeführt werden soll:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;inReplyTo&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;url&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub-static-site-de.html&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;attributedTo&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/users/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;to&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://www.w3.org/ns/activitystreams#Public&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;cc&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/collections/followers/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://chaos.social/users/s3lph&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Titel und Inhalt des Artikels können mehrsprachig angegeben werden.
Allerdings werden &lt;code&gt;contentMap&lt;/code&gt; und &lt;code&gt;nameMap&lt;/code&gt; nur von wenigen
ActivityPub-Servern implementiert.  Die meisten Server zeigen einfach
immer den unübersetzten Standard-Inhalt an:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;How much ActivityPub can a Static Site Generator implement?&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;nameMap&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;en&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;How much ActivityPub can a Static Site Generator implement?&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;de&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Wie viel ActivityPub kann ein Static Site Generator?&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;content&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&amp;lt;p&amp;gt;There already are multiple blogging solutions...&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;contentMap&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;en&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&amp;lt;p&amp;gt;There already are multiple blogging solutions...&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;de&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&amp;lt;p&amp;gt;Es gibt bereits verschiedene Blog-Lösungen...&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;summary&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Schlussendlich werden - genau wie bei Personen auch - weitere Daten
wie Tags oder Erwähnungen anderer Personen in maschinenlesbar
aufgeführt:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;attachment&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;tag&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Hashtag&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;#activitypub&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;href&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/tags/activitypub&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Hashtag&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;#pelican&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;href&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://s3lph.me/activitypub/tags/pelican&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Mention&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;href&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://chaos.social/users/s3lph&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;@s3lph@chaos.social&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Damit haben wir Autoren und deren Artikel vollständig als
ActivityPub-Ressourcen abgebildet.  Damit ist auch alles
implementiert, was sich sinnvoll als rein statische Seite
implementieren lässt.&lt;/p&gt;
&lt;p&gt;Der Code, um diese ActivityPub-Ressourcen zu erzeugen, ist als
&lt;a href="https://git.kabelsalat.ch/s3lph/pelican-plugin-activitypub"&gt;Pelican-Plugin&lt;/a&gt; verfügbar.  Bevor irgendjemand das Plugin in
das eigene Pelican einbaut, würde ich aber dazu raten, diesen Artikel
bis zum Ende zu lesen.&lt;/p&gt;
&lt;h2 id="kompatibilitat-mit-fediverse-diensten"&gt;Kompatibilität mit Fediverse-Diensten&lt;/h2&gt;
&lt;p&gt;Zum Testen habe drei verschiedenen Fediverse-Testinstanzen aufgesetzt,
um mit meinem Blog zu interagieren: &lt;a href="https://joinmastodon.org/"&gt;Mastodon&lt;/a&gt;,
&lt;a href="https://pleroma.social/"&gt;Pleroma&lt;/a&gt; und &lt;a href="https://join.misskey.page/"&gt;Misskey&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Mit allen drei Diensten kann das Profil &lt;code&gt;@s3lph@s3lph.me&lt;/code&gt; aufgerufen
werden.  Auch die &lt;strong&gt;Anzahl&lt;/strong&gt; an Blogartikeln wird korrekt dargestellt,
allerdings werden die Artikel selbst nicht angezeigt.  Wie sich
herausstellt, werden Artikel von anderen Instanzen üblicherweise nicht
automatisch geladen.&lt;/p&gt;
&lt;p&gt;Die Artikel würden nur dann angezeigt, wenn sie von ihrer
Ursprungsinstanz in die Inbox der Zielinstanz gePOSTed werden.
Alternativ war es aber bei allen drei Testinstanzen möglich, die URL
des Artikels in der Suche einzugeben und so aufzurufen.  Danach wird
der jeweilige Artikel auch in der Timeline des Autors angezeigt.&lt;/p&gt;
&lt;p&gt;Leider ist dies eigentlich auch schon alles, dass mit einer rein
statischen Implementation wirklich funktioniert.  Insbesondere die
folgenden - doch recht zentralen - Funktionen sind nicht verfügbar:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Folgen&lt;/strong&gt;: Wenn Alice im Fediverse dem Account vom Bob folgen
  möchte, sendet sie eine &lt;em&gt;Follow Request&lt;/em&gt; in die Inbox von Bob.  Bob
  (resp. Bob's Instanz) muss diese Anfrage allerdings erst bestätigen.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Antworten&lt;/strong&gt;: Wenn Alice auf eine Nachricht von Bob antwortet, ist
  diese Antwort zunächst nur auf der Instanz von Alice sichtbar.  Die
  Antwort wird auch in die Inbox von Bob zugestellt, und Bobs Instanz
  wäre dafür zuständig, die Antwort an alle anderen involvierten
  Instanzen weiterzuleiten.  Dieser Schritt bleibt im statisch
  generierten Fall aber aus.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Löschen&lt;/strong&gt;: Wenn ein Artikel im Cache einer anderen Instanz ist,
  wird er dort üblicherweise behalten, bis er von der Ursprungsinstanz
  explizit gelöscht wird.  Hierzu muss eine signierte Löschanfrage an
  die Inbox der anderen Instanz gesendet werden.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Aktualisieren&lt;/strong&gt;: Für Änderungen an Artikeln gelten die gleichen
  Einschränkungen wie für das Löschen von Artikeln.  So wird dieses
  Listenelement niemals auf Instanzen auftauchen, die bereits eine
  ältere Version des Artikels in ihrem Cache haben.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Was hingegen funktioniert sind Likes und Boosts, allerdings werden
diese nicht in den Zählern unter den Artikeln reflektiert.&lt;/p&gt;
&lt;p&gt;Zudem stellt z.B. Mastodon nur einen Link zum Artikel dar, da die hier
verwendeten &lt;code&gt;Article&lt;/code&gt;-Objekte nicht vollständig unterstützt werden;
für Kurznachrichten wird der Objecttyp &lt;code&gt;Note&lt;/code&gt; verwendet.  Sowohl
Pleroma als auch Misskey können den Artikel aber vollständig nativ
darstelle.&lt;/p&gt;
&lt;h2 id="fazit"&gt;Fazit&lt;/h2&gt;
&lt;p&gt;Die Frage «Wie viel ActivityPub kann ein Static Site Generator?» lässt
sich zusammenfassend wohl am besten beantworten mit «viel, aber nicht
genug, um praktische Relevanz zu haben.»  Auch für diesen Blog wird
eine rein statische ActivityPub-Implementation wahrscheinlich keine
wirkliche Relevanz haben, aber die Artikel werden voraussichtlich
weiterhin in dieser eingeschränkten Form im Fediverse verfügbar sein.&lt;/p&gt;
&lt;p&gt;PS: Falls du diesen Artikel im Fediverse liest, und eine Antwort
schreiben willst, adressiere deine Antwort bitte (zusätzlich) an
&lt;code&gt;@s3lph@chaos.social&lt;/code&gt;, sonst bekomme ich davon nichts mit.&lt;/p&gt;</content><category term="hacks"/><category term="ActivityPub"/><category term="Pelican"/></entry><entry><title>Automated Generation of POI Maps from Open Data</title><link href="https://s3lph.me/automated-generation-poi-maps-open-data.html" rel="alternate"/><published>2022-10-27T02:00:00+02:00</published><updated>2022-10-27T02:00:00+02:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2022-10-27:/automated-generation-poi-maps-open-data.html</id><summary type="html">&lt;p&gt;&lt;a href="https://www.ccc.de/regional"&gt;The website of the Chaos Computer Club features a map&lt;/a&gt; of
Germany and its surrounding countries, where hackerspaces who consider
themselves as part of the CCC are marked.&lt;/p&gt;
&lt;p&gt;Up until now, this map (shown in &lt;a href="#figure1"&gt;Figure 1&lt;/a&gt;) had been created
and updated manually in a time consuming process.  Not only …&lt;/p&gt;</summary><content type="html">&lt;p&gt;&lt;a href="https://www.ccc.de/regional"&gt;The website of the Chaos Computer Club features a map&lt;/a&gt; of
Germany and its surrounding countries, where hackerspaces who consider
themselves as part of the CCC are marked.&lt;/p&gt;
&lt;p&gt;Up until now, this map (shown in &lt;a href="#figure1"&gt;Figure 1&lt;/a&gt;) had been created
and updated manually in a time consuming process.  Not only did this
cause the map to be almost always outdated, but the process was also
error-prone, with many hackerspaces being misplaced.&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2022-10-27-erfamap-old.png"&gt;
    &lt;img src="https://s3lph.me/images/2022-10-27-erfamap-old.png"
         title="The old, manually created map of hackerspaces."
         alt="The old, manually created map of hackerspaces."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure1" class="figureid"&gt;Figure 1:&lt;/a&gt;
    The old, manually created map of hackerspaces, last updated 2018.
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;The desire to automate the creation of this map had been voiced
multiple times over the last few years, but all previous attempts have
never been finished.  Since I was one of the last persons asking for
the map to be updated, I went ahead and started my own attempt at
generating this map automatically.&lt;/p&gt;
&lt;p&gt;My plan was to generate the map entirely from Open Data sources (apart
from the actual hackerspace locations, where the "single source of
truth" is a password-protected MediaWiki server).  I also planned to
generate the map background, rather than using a preexisting map, in
order to avoid dealing with map projection issues.&lt;/p&gt;
&lt;h2 id="country-borders-from-wikidata"&gt;Country Borders from Wikidata&lt;/h2&gt;
&lt;p&gt;In order to generate the map's background layer, the borders of
Germany's federal states as well as surrounding countries were
retrieved from &lt;a href="https://www.wikidata.org/wiki/Wikidata:Main_Page"&gt;Wikidata&lt;/a&gt;.  Geographic regions in Wikidata
are (or can be) equipped with the &lt;a href="https://www.wikidata.org/wiki/Property:P3896"&gt;«geoshape»&lt;/a&gt; property,
describing the outline of the region as a GeoJSON file.&lt;/p&gt;
&lt;p&gt;For the federal states, the SPARQL query to retrieve these geoshape
files is fairly simple:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="nv"&gt;?item&lt;/span&gt; &lt;span class="nv"&gt;?map&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c"&gt;# ?item is instance of &amp;quot;Federal State of Germany&amp;quot;&lt;/span&gt;
  &lt;span class="nv"&gt;?item&lt;/span&gt; &lt;span class="nn"&gt;wdt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;P31&lt;/span&gt; &lt;span class="nn"&gt;wd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;Q1221156&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
  &lt;span class="c"&gt;# ?item has geoshape ?map&lt;/span&gt;
  &lt;span class="nv"&gt;?item&lt;/span&gt; &lt;span class="nn"&gt;wdt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;P3896&lt;/span&gt; &lt;span class="nv"&gt;?map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Unfortunately, the same can't be said for the query to retrieve the
geoshapes of all European countries, due to data being somewhat
inconsistent:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;When multiple properties of the same type are defined for a subject,
  they may carry different ranks.  If one of these properties has a
  &lt;a href="https://www.wikidata.org/wiki/Help:Ranking#Preferred_rank"&gt;preferred rank&lt;/a&gt;, Wikidata will consider it «truthy» and not
  return any other properties of the same type.&lt;/p&gt;
&lt;p&gt;Some countries had multiple geoshape properties, differing in
  resolution («zoom level»), with the most accurate geoshape being
  considered preferred.  However, in order to properly render a map,
  all countries borders needed to have the same resolution and come
  from the same data source, so that the borders of two neighbouring
  countries would coincide.  To achieve this, the query needed to be
  crafted so that even non-preferred geoshapes were returned.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;The type (preferred «instance of» property) of some countries was
  "instance of country", while others were considered "instance of
  sovereign state".  Both requirements needed to be included in the
  query.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;While some countries are labeled as "part of Europe", some others are
  labeled as e.g. "part of Central Europe", which would then again be
  "part of Europe".  The "part of Europe" constraint thus needed to be
  declared transitively.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Some countries were labeled neither "part of Europe" nor "on
  continent Europe" at all.  However, coincidentally, all the
  countries where these relationships were not defined, were labeled
  as "part of the European Economic Area".&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Finally, taking all these inconsistencies into consideration, the
query to retrieve the borders of all European countries looked like
this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="nv"&gt;?item&lt;/span&gt; &lt;span class="nv"&gt;?map&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c"&gt;# ?item is instance of country or sovereign state (see filter below)&lt;/span&gt;
  &lt;span class="nv"&gt;?item&lt;/span&gt; &lt;span class="nn"&gt;wdt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;P31&lt;/span&gt; &lt;span class="nv"&gt;?stateclass&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
  &lt;span class="c"&gt;# ?item is transitively (+) part of Europe (Contintent) or EEA (see filter below)&lt;/span&gt;
  &lt;span class="nv"&gt;?item&lt;/span&gt; &lt;span class="nn"&gt;wdt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;P361&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nv"&gt;?euroclass&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
  &lt;span class="c"&gt;# ?item has geoshape ?map (including all non-deprecated results)&lt;/span&gt;
  &lt;span class="nv"&gt;?item&lt;/span&gt; &lt;span class="nn"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;P3896&lt;/span&gt; &lt;span class="nv"&gt;?st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;  &lt;span class="c"&gt;# ?item has geoshape statement ?st&lt;/span&gt;
  &lt;span class="nv"&gt;?st&lt;/span&gt; &lt;span class="nn"&gt;ps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;P3896&lt;/span&gt; &lt;span class="nv"&gt;?map&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c"&gt;# ?st has geoshape value ?map&lt;/span&gt;
  &lt;span class="k"&gt;MINUS&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;?st&lt;/span&gt; &lt;span class="nn"&gt;wikibase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;rank&lt;/span&gt; &lt;span class="nn"&gt;wikibase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;DeprecatedRank&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c"&gt;# Exclude results where the ?st rank is &amp;quot;deprecated&amp;quot;&lt;/span&gt;
  &lt;span class="c"&gt;# ?stateclass is &amp;quot;Country&amp;quot; or &amp;quot;Sovereign State&amp;quot;&lt;/span&gt;
  &lt;span class="k"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;?stateclass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;wd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;Q6256&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;?stateclass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;wd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;Q3624078&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;
  &lt;span class="c"&gt;# ?euroclass is &amp;quot;Europe (Continent)&amp;quot; or &amp;quot;European Economic Area&amp;quot;&lt;/span&gt;
  &lt;span class="k"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;?euroclass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;wd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;Q46&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;?euroclass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;wd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;Q8932&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;This query returned all the countries required for the map.  It also
included some of these countries' overseas territories, but those were
then filtered at a later stage by comparing them to the chosen
viewport of the generated map.&lt;/p&gt;
&lt;h2 id="hackerspace-names-and-addresses-from-semantic-mediawiki"&gt;Hackerspace Names and Addresses from Semantic MediaWiki&lt;/h2&gt;
&lt;p&gt;There is a CCC-internal MediaWiki instance which makes heavy use of
the &lt;a href="https://www.semantic-mediawiki.org/wiki/Semantic_MediaWiki"&gt;Semantic MediaWiki&lt;/a&gt; (SMW) extension.  Among lots of other
things, every hackerspace which considers itself part of CCC is listed
there, along with their properties (e.g. street addresses).&lt;/p&gt;
&lt;p&gt;SMW makes these properties machine-readable and queryable through
different interfaces.  For example, queries can be formulated directly
within MediaWiki pages to automatically generate and update tables or
lists based on these properties.  Another interface is a collection of
HTTP API endpoints, making SMW queryable from the outside.&lt;/p&gt;
&lt;p&gt;Among these API endpoints, I ended up using the &lt;a href="https://www.semantic-mediawiki.org/wiki/Help:Semantic_search"&gt;Semantic
Search&lt;/a&gt; JSON API to retrieve a list of all hackerspaces
along with their addresses, display names (there can be multiple) and
website URLs:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nf"&gt;GET&lt;/span&gt; &lt;span class="nn"&gt;/index.php?title=Spezial:Semantische_Suche&amp;amp;x=[[Category:Erfa-Kreise]][[Chaostreff-Active::wahr]]/?Chaostreff-City/?Chaostreff-Nickname/?Chaostreff-Physical-Address/...&amp;amp;format=json&lt;/span&gt; &lt;span class="kr"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt;

{
    &amp;quot;printrequests&amp;quot;: [...],
    &amp;quot;results&amp;quot;: {
        &amp;quot;Chaos Computer Club Basel&amp;quot;: {
            &amp;quot;printouts&amp;quot;: {
                &amp;quot;Chaostreff-City&amp;quot;: [
                    &amp;quot;Basel&amp;quot;
                ],
                &amp;quot;Chaostreff-Physical-Address&amp;quot;: [
                    &amp;quot;Birsfelderstrasse&amp;quot;
                ],
                &amp;quot;Chaostreff-Physical-Housenumber&amp;quot;: [
                    &amp;quot;6&amp;quot;
                ],
                &amp;quot;Chaostreff-Physical-Postcode&amp;quot;: [
                    &amp;quot;4132&amp;quot;
                ],
                &amp;quot;Chaostreff-Physical-City&amp;quot;: [
                    &amp;quot;Muttenz&amp;quot;
                ],
                &amp;quot;Chaostreff-Country&amp;quot;: [
                    &amp;quot;Schweiz&amp;quot;
                ],
                &amp;quot;Public-Web&amp;quot;: [
                    &amp;quot;https://www.ccc-basel.ch/&amp;quot;
                ],
                &amp;quot;Chaostreff-Longname&amp;quot;: [
                    &amp;quot;Chaos Computer Club Basel&amp;quot;
                ],
                &amp;quot;Chaostreff-Nickname&amp;quot;: [
                    &amp;quot;CCC Basel&amp;quot;
                ],
                &amp;quot;Chaostreff-Realname&amp;quot;: [
                    &amp;quot;Chaos Computer Club Basel&amp;quot;
                ]
            },
            &amp;quot;fulltext&amp;quot;: &amp;quot;Chaos Computer Club Basel&amp;quot;,
            &amp;quot;fullurl&amp;quot;: &amp;quot;https://doku.ccc.de/Chaos_Computer_Club_Basel&amp;quot;,
            &amp;quot;namespace&amp;quot;: 0,
            &amp;quot;exists&amp;quot;: &amp;quot;1&amp;quot;,
            &amp;quot;displaytitle&amp;quot;: &amp;quot;&amp;quot;
        },
        ...
    },
    &amp;quot;serializer&amp;quot;: &amp;quot;SMW\\Serializers\\QueryResultSerializer&amp;quot;,
    &amp;quot;version&amp;quot;: 2,
    &amp;quot;rows&amp;quot;: 16
}
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Of course the request params would be URL-encoded, in this example
they are unencoded for better readability.&lt;/p&gt;
&lt;p&gt;Now that we have the full list of hackerspaces, there is one more step
required to display them on a map: Only the street addresses are
entered in this wiki.  In order to display them on a map, the
addresses need to be geocoded, i.e. translated into coordinates. One
such geocoding service is &lt;a href="https://nominatim.org/"&gt;Nominatim&lt;/a&gt;. It uses
OpenStreetMap to look up addresses and return WGS84 coordinates.&lt;/p&gt;
&lt;p&gt;However, not all the street addresses returned by SMW are resolvable:
Some hackerspaces encode additional information (e.g. the building
level) in unstandardized, non-machine-readable formats.  Others
temporarily don't have a street address at all, e.g. because they are
moving between places.  In order to handle these special cases, less
accurate addresses are queried, if a more accurate address fails to
resolve.  For example, in order to resolve the location of CCC Basel, the
following address formats would be considered:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Birsfelderstrasse 6, 4132 Muttenz, Schweiz&lt;/li&gt;
&lt;li&gt;4132 Muttenz, Schweiz&lt;/li&gt;
&lt;li&gt;Muttenz, Schweiz&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="automatic-label-layouting"&gt;Automatic Label Layouting&lt;/h2&gt;
&lt;p&gt;Finally, we have all the data to render the map.  In a first step,
both the country borders and the hackerspace locations are transformed
from their polar 3D longitude/latitude representation to a 2D
Cartesian projection suitable for display on a map.&lt;/p&gt;
&lt;p&gt;However, the map should also display labels for some of the
hackerspaces (so-called &lt;a href="https://www.ccc.de/de/club/erfas"&gt;Erfas&lt;/a&gt;), and these labels should
neither overlap with each other, nor should they obstruct other
hackerspaces marked on the map.  On the old map, these labels had been
laid out manually to avoid overlap.  For the new map, this too should
be automated.&lt;/p&gt;
&lt;p&gt;For each label to be placed, the text is rendered using the chosen
font, and measured to compute its bounding box.  From this bounding
box, a discrete set of candidates for placement of this label is
generated.  &lt;a href="#figure2"&gt;Figure 2&lt;/a&gt; depicts a set of bounding box
candidates for CCC Berlin, placed around the marker of CCC Berlin.&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2022-10-27-erfamap-layout-candidates.png"&gt;
    &lt;img src="https://s3lph.me/images/2022-10-27-erfamap-layout-candidates.png"
         title="Bounding boxes of all label position candidates around CCC Berlin."
         alt="Bounding boxes of all label position candidates around CCC Berlin."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure2" class="figureid"&gt;Figure 2:&lt;/a&gt;
    Bounding boxes of all label position candidates around CCC Berlin.
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Weights are assigned to each candidate based on multiple factors.  The
exact weights are magic numbers obtained through trial and error, but
it mainly boils down to these factors:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Prefer candidates closer to their marker&lt;/li&gt;
&lt;li&gt;Prefer candidates furthest away from other markers and (already placed) labels&lt;/li&gt;
&lt;li&gt;Prefer candidates to the left and right over those at the top or bottom&lt;/li&gt;
&lt;li&gt;Prefer candidates to the right over those to the left (unless close to the right-hand side of the map)&lt;/li&gt;
&lt;li&gt;Penalize candidates overlapping with other elements with a huge weight&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The actual label placement happens in three steps:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;If a label has at least one candidate that doesn't overlap with any
   other label candidate or marker, this candidate is chosen.  Since
   this step eliminates the other candidates of the label, it may
   render candidates of other labels usable again, if they were were
   obstructed before.&lt;/li&gt;
&lt;li&gt;If there are still some unplaceable labels, they are placed where
   they cause the least overlap.&lt;/li&gt;
&lt;li&gt;All weights are recomputed and already placed labels are optimized
   based on these weights.  This will reduce possible overlap and
   spread labels further apart, making the map better readable.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This algorithm may not be perfect, but it seems to find a
locally-optimized solution which at least to the human eye does not
look too bad.&lt;/p&gt;
&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;After all the labels are laid out, an SVG image is generated and
written to a file.  A CSS stylesheet is embedded in the SVG file so it
can be used standalone, either for directly displaying it in a
web browser, or for further processing.  The markers and labels in the
SVG image are amended with clickable links, leading the user to each
hackerspace's website.&lt;/p&gt;
&lt;p&gt;Additionally, a PNG image is generated, alongside with a HTML imagemap
file (with the same links as in the SVG) so that the map can be used
interactively in web browsers with limited or no SVG support.&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2022-10-27-erfamap-new.png"&gt;
    &lt;img src="https://s3lph.me/images/2022-10-27-erfamap-new.png"
         title="The new, fully automatically created map, as it is now also available on ccc.de."
         alt="The new, fully automatically created map, as it is now also available on ccc.de."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure3" class="figureid"&gt;Figure 3:&lt;/a&gt;
    The new, fully automatically created map, as it is now also available on ccc.de.
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;The new map (as seen in &lt;a href="#figure3"&gt;Figure 3&lt;/a&gt;) has now replaced the old
one on &lt;a href="https://www.ccc.de/regional"&gt;ccc.de&lt;/a&gt;.  The entire script to generate the map can be
found in &lt;a href="https://git.kabelsalat.ch/s3lph/erfamap"&gt;my Gitea repo&lt;/a&gt;.  However, in order to actually use this
script as-is, you need to know the Basic Auth credentials for the CCC
MediaWiki instance.  If you just want to check it out, you can use the
example data in the repo, as described in the README.&lt;/p&gt;</content><category term="hacks"/><category term="Open Data"/><category term="Wikidata"/><category term="OpenStreetMap"/></entry><entry><title>Automatische Erzeugung von POI-Karten aus Offenen Daten</title><link href="https://s3lph.me/automated-generation-poi-maps-open-data-de.html" rel="alternate"/><published>2022-10-27T02:00:00+02:00</published><updated>2022-10-27T02:00:00+02:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2022-10-27:/automated-generation-poi-maps-open-data-de.html</id><summary type="html">&lt;p&gt;&lt;a href="https://www.ccc.de/regional"&gt;Die Website des Chaos Computer Clubs enthält eine Karte&lt;/a&gt; von
Deutschland und den umliegenden Ländern, auf der Hackerspaces
verzeichnet sind, die sich als Teil des CCC betrachten.&lt;/p&gt;
&lt;p&gt;Bisher wurde diese Karte (siehe &lt;a href="#figure1"&gt;Abbildung 1&lt;/a&gt;) händisch
erzeugt und aktualisiert, was jeweils sehr zeitaufwändig war.  Das hat
nicht nur dazu geführt, dass …&lt;/p&gt;</summary><content type="html">&lt;p&gt;&lt;a href="https://www.ccc.de/regional"&gt;Die Website des Chaos Computer Clubs enthält eine Karte&lt;/a&gt; von
Deutschland und den umliegenden Ländern, auf der Hackerspaces
verzeichnet sind, die sich als Teil des CCC betrachten.&lt;/p&gt;
&lt;p&gt;Bisher wurde diese Karte (siehe &lt;a href="#figure1"&gt;Abbildung 1&lt;/a&gt;) händisch
erzeugt und aktualisiert, was jeweils sehr zeitaufwändig war.  Das hat
nicht nur dazu geführt, dass die Karte oft nicht mehr aktuell war,
sondern der Prozess war auch fehleranfällig, und die einzelnen
Hackerspaces waren oft nicht ganz korrekt platziert.&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2022-10-27-erfamap-old.png"&gt;
    &lt;img src="https://s3lph.me/images/2022-10-27-erfamap-old.png"
         title="Die alte, händisch erzeugte Karte der Hackerspaces."
         alt="Die alte, händisch erzeugte Karte der Hackerspaces."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure1" class="figureid"&gt;Abbildung 1:&lt;/a&gt;
    Die alte, händisch erzeugte Karte der Hackerspaces.
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Der Wunsch, die Erzeugung dieser Karte zu automatisieren, wurde in den
letzten Jahren mehrfach geäussert, aber bisherige Ansätze wurden nie
fertiggestellt.  Da ich eine der letzten Personen war, die mal wieder
darum gebeten hatten, die Karte zu aktualisiert, habe ich einfach
kurzerhand selber versucht, die Erzeugung der Karte zu automatisieren.&lt;/p&gt;
&lt;p&gt;Mein Plan war, die gesamte Karte aus offenen Daten zu erzeugen
(ausgenommen von den Adressen der Hackerspaces, die aus einem
passwortgeschützten MediaWiki kommen).  Auch den Hintergrund der Karte
wollte ich dynamisch erzeugen, anstatt eine bestehende Karte zu
verwenden, um mich nicht mit abweichenden Kartenprojektionen
herumschlagen zu müssen.&lt;/p&gt;
&lt;h2 id="landergrenzen-aus-wikidata"&gt;Ländergrenzen aus Wikidata&lt;/h2&gt;
&lt;p&gt;Um den Hintergrund der Karte zu erzeugen, wurden die Grenzverläufe der
Bundesländer Deutschlands sowie der umliegenden Länder von
&lt;a href="https://www.wikidata.org/wiki/Wikidata:Main_Page"&gt;Wikidata&lt;/a&gt; heruntergeladen.  Geografische Regionen werden
in Wikidata mit dem Attribut &lt;a href="https://www.wikidata.org/wiki/Property:P3896"&gt;«geoshape»&lt;/a&gt; versehen (resp. können damit
versehen werden), welches den Grenzverlauf der Region als
GeoJSON-Datei beschreibt.&lt;/p&gt;
&lt;p&gt;Die geoshape-Dateien für die Bundesländer können via SPARQL recht
einfach abgerufen werden:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="nv"&gt;?item&lt;/span&gt; &lt;span class="nv"&gt;?map&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c"&gt;# ?item ist vom Typ &amp;quot;Bundesland von Deutschland&amp;quot;&lt;/span&gt;
  &lt;span class="nv"&gt;?item&lt;/span&gt; &lt;span class="nn"&gt;wdt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;P31&lt;/span&gt; &lt;span class="nn"&gt;wd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;Q1221156&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
  &lt;span class="c"&gt;# ?item hat die geoshape ?map&lt;/span&gt;
  &lt;span class="nv"&gt;?item&lt;/span&gt; &lt;span class="nn"&gt;wdt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;P3896&lt;/span&gt; &lt;span class="nv"&gt;?map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Leider ist es nicht mehr ganz so einfach, die Grenzverläufe aller
europäischen Länder abzurufen, da die Daten nicht sonderlich
konsistent sind:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Wenn ein Object in Wikidata mehrere Attribute vom gleichen Typ
  besitzt, können diesen unterschiedliche «Ränge» («ranks») zugewiesen
  werden.  Falls eins dieser Attribute den Rang &lt;a href="https://www.wikidata.org/wiki/Help:Ranking#Preferred_rank"&gt;«bevorzugt»
  («preferred»)&lt;/a&gt; trägt, wird es von Wikidata als «die
  Wahrheit» betrachtet, und andere Attribute vom gleichen Typ werden
  nicht zurückgegeben.&lt;/p&gt;
&lt;p&gt;Einige Länder haben mehrere geoshapes, die sich in ihrer
  Auflösung («zoom level») unterschieden, wobei der am höchsten
  aufgelöste Grenzverlauf bevorzugt wird.  Um allerdings aus den
  Grenzverläufen sinnvoll eine Karte zu erzeugen, sollten alle
  Verläufe die gleiche Auflösung haben und idealerweise aus der
  gleichen Quelle stammen, damit die Grenzen von benachbarten Ländern
  genau übereinander liegen.  Um an diese zusätzlichen Auflösungsstufen
  zu kommen, muss die SPARQL-Abfrage angepasst werden, um auch nicht
  bevorzugte Attribute zurückzugeben.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Der Typ (bevorzugtes «instance of»-Attribute) ist bei manchen
  Ländern «country», bei anderen Ländern «sovereign state».  Um alle
  Länder abzudecken, müssen beide Werte berücksichtigt werden.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Manche Länder sind als Teil von Europa («part of Europe»)
  gekennzeichnet, während andere z.B. als Teil on Zentraleuropa («part
  of Central Europe») gekennzeichnet sind, welches wiederum «part of
  Europe» ist. Der «part of Europe»-Filter muss daher transitiv
  angegeben werden.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Manche Länder waren weder «part of Europe» noch «on continent
  Europe».  Zufälligerweise waren aber alle solche Länder auch Teil
  des Europäischen Wirtschaftsraums («part of the European Economic
  Area»).&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Wenn man diese Inkonsistenzen alle in Betracht zieht, kommt man auf
folgende SPARQL-Query, um die Grenzverläufe aller Länder in Europa
abzurufen:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="nv"&gt;?item&lt;/span&gt; &lt;span class="nv"&gt;?map&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c"&gt;# ?item ist vom Typ «country» oder «sovereign state» (siehe FILTER weiter unten)&lt;/span&gt;
  &lt;span class="nv"&gt;?item&lt;/span&gt; &lt;span class="nn"&gt;wdt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;P31&lt;/span&gt; &lt;span class="nv"&gt;?stateclass&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
  &lt;span class="c"&gt;# ?item ist transitiv (+) Teil von «Europe (Contintent)» oder «EEA» (siehe FILTER weiter unten)&lt;/span&gt;
  &lt;span class="nv"&gt;?item&lt;/span&gt; &lt;span class="nn"&gt;wdt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;P361&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nv"&gt;?euroclass&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
  &lt;span class="c"&gt;# ?item hat die geoshape ?map (inklusive aller Resultate, die nicht den Rang «veraltet» («deprecated») haben)&lt;/span&gt;
  &lt;span class="nv"&gt;?item&lt;/span&gt; &lt;span class="nn"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;P3896&lt;/span&gt; &lt;span class="nv"&gt;?st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;  &lt;span class="c"&gt;# ?item hat ein geoshape-Statement ?st&lt;/span&gt;
  &lt;span class="nv"&gt;?st&lt;/span&gt; &lt;span class="nn"&gt;ps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;P3896&lt;/span&gt; &lt;span class="nv"&gt;?map&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c"&gt;# Das statement ?st hat die geoshape ?map&lt;/span&gt;
  &lt;span class="k"&gt;MINUS&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;?st&lt;/span&gt; &lt;span class="nn"&gt;wikibase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;rank&lt;/span&gt; &lt;span class="nn"&gt;wikibase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;DeprecatedRank&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c"&gt;# Alle Resultate ignorieren, deren Rang «deprecated» ist&lt;/span&gt;
  &lt;span class="c"&gt;# ?stateclass ist &amp;quot;Country&amp;quot; oder &amp;quot;Sovereign State&amp;quot;&lt;/span&gt;
  &lt;span class="k"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;?stateclass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;wd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;Q6256&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;?stateclass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;wd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;Q3624078&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;
  &lt;span class="c"&gt;# ?euroclass ist &amp;quot;Europe (Continent)&amp;quot; oder &amp;quot;European Economic Area&amp;quot;&lt;/span&gt;
  &lt;span class="k"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;?euroclass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;wd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;Q46&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;?euroclass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;wd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;Q8932&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Mit dieser Query kommen alle Länder zusammen, die für die Karte
benötigt werden.  Zwar sind dort bei einigen Ländern noch die
Übersee-Territorien mit dabei, diese werden aber später entfernt, wenn
die Kartenränder festgelegt werden.&lt;/p&gt;
&lt;h2 id="hackerspace-namen-und-adressen-von-semantic-mediawiki"&gt;Hackerspace-Namen und -Adressen von Semantic MediaWiki&lt;/h2&gt;
&lt;p&gt;Das CCC-interne MediaWiki macht starken Gebrauch von der Erweiterung
&lt;a href="https://www.semantic-mediawiki.org/wiki/Semantic_MediaWiki"&gt;Semantic MediaWiki&lt;/a&gt; (SMW).  Unter vielen anderen Daten ist dort
jeder Hackerspace aufgeführt, der sich als Teil des CCC sieht,
inklusive seiner Attribute (wie z.B. Postanschriften).&lt;/p&gt;
&lt;p&gt;SMW macht diese Attribute maschinenlesbar und ermöglicht die Abfrage
dieser Attribute über verschiedenen Schnittstellen.  Zum Beispiel
können so direkt in MediaWiki-Seiten Abfragen eingebunden werden, die
automatisch Tabellen oder Listen aus diesen Daten generieren und
aktualisieren.  Zudem gibt es noch verschiedene HTTP-Schnittstellen,
durch welche SMW auch durch die Aussenwelt abgefragt werden kann.&lt;/p&gt;
&lt;p&gt;Um die Liste aller Hackerspaces inklusive ihrer Adressen, Namen
(wovon es mehrere geben kann) und Website-URLs abzufragen, habe ich
die &lt;a href="https://www.semantic-mediawiki.org/wiki/Help:Semantic_search"&gt;Semantic Search&lt;/a&gt; JSON-API verwendet:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nf"&gt;GET&lt;/span&gt; &lt;span class="nn"&gt;/index.php?title=Spezial:Semantische_Suche&amp;amp;x=[[Category:Erfa-Kreise]][[Chaostreff-Active::wahr]]/?Chaostreff-City/?Chaostreff-Nickname/?Chaostreff-Physical-Address/...&amp;amp;format=json&lt;/span&gt; &lt;span class="kr"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt;

{
    &amp;quot;printrequests&amp;quot;: [...],
    &amp;quot;results&amp;quot;: {
        &amp;quot;Chaos Computer Club Basel&amp;quot;: {
            &amp;quot;printouts&amp;quot;: {
                &amp;quot;Chaostreff-City&amp;quot;: [
                    &amp;quot;Basel&amp;quot;
                ],
                &amp;quot;Chaostreff-Physical-Address&amp;quot;: [
                    &amp;quot;Birsfelderstrasse&amp;quot;
                ],
                &amp;quot;Chaostreff-Physical-Housenumber&amp;quot;: [
                    &amp;quot;6&amp;quot;
                ],
                &amp;quot;Chaostreff-Physical-Postcode&amp;quot;: [
                    &amp;quot;4132&amp;quot;
                ],
                &amp;quot;Chaostreff-Physical-City&amp;quot;: [
                    &amp;quot;Muttenz&amp;quot;
                ],
                &amp;quot;Chaostreff-Country&amp;quot;: [
                    &amp;quot;Schweiz&amp;quot;
                ],
                &amp;quot;Public-Web&amp;quot;: [
                    &amp;quot;https://www.ccc-basel.ch/&amp;quot;
                ],
                &amp;quot;Chaostreff-Longname&amp;quot;: [
                    &amp;quot;Chaos Computer Club Basel&amp;quot;
                ],
                &amp;quot;Chaostreff-Nickname&amp;quot;: [
                    &amp;quot;CCC Basel&amp;quot;
                ],
                &amp;quot;Chaostreff-Realname&amp;quot;: [
                    &amp;quot;Chaos Computer Club Basel&amp;quot;
                ]
            },
            &amp;quot;fulltext&amp;quot;: &amp;quot;Chaos Computer Club Basel&amp;quot;,
            &amp;quot;fullurl&amp;quot;: &amp;quot;https://doku.ccc.de/Chaos_Computer_Club_Basel&amp;quot;,
            &amp;quot;namespace&amp;quot;: 0,
            &amp;quot;exists&amp;quot;: &amp;quot;1&amp;quot;,
            &amp;quot;displaytitle&amp;quot;: &amp;quot;&amp;quot;
        },
        ...
    },
    &amp;quot;serializer&amp;quot;: &amp;quot;SMW\\Serializers\\QueryResultSerializer&amp;quot;,
    &amp;quot;version&amp;quot;: 2,
    &amp;quot;rows&amp;quot;: 16
}
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;(Üblicherweise würden die HTTP-Request-Parameter URL-encoded werden,
in diesem Beispiel sind sie zwecks Lesbarkeit aber nicht encoded.)&lt;/p&gt;
&lt;p&gt;Jetzt haben wir die gesamte Liste an Hackerspaces, aber bevor diese
auf einer Karte dargestellt werden können, ist noch ein weiterer
Schritt nötig: Das Wiki enthält nur Adressen.  Um die Hackerspaces auf
einer Karte darzustellen, müssen diese erst &lt;em&gt;geocoded&lt;/em&gt; werden, d.h. in
Geokoordinaten übersetzt werden.  Ein solcher Geocoding-Dienst ist
&lt;a href="https://nominatim.org/"&gt;Nominatim&lt;/a&gt;.  Nominatim verwendet OpenStreetMap, um
Adressen nachzuschlagen und in WGS84-Koordinaten zu übersetzten.&lt;/p&gt;
&lt;p&gt;Leider sind nicht alle der Adressen, die vom SMW zurückkommen,
auflösbar: Manche Hackerspaces tragen in den Adress-Attributen
zusätzliche Informationen (wie z.B. das Stockwerk) ein, die weder
standardisiert, noch maschinenlesbar sind.  Andere Spaces haben
vorübergehend gar keine Adresse, z.B. weil sich gerade am umziehen
sind.  Um diese Fälle zu behandeln, werden zusätzliche, weniger genaue
Adressen abgefragt, wenn eine genauere Adresse nicht auflösbar ist.
So werden z.B. für den CCC Basel die folgenden Adressformate in
Erwägung gezogen:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Birsfelderstrasse 6, 4132 Muttenz, Schweiz&lt;/li&gt;
&lt;li&gt;4132 Muttenz, Schweiz&lt;/li&gt;
&lt;li&gt;Muttenz, Schweiz&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="automatische-anordnung-von-beschriftungen"&gt;Automatische Anordnung von Beschriftungen&lt;/h2&gt;
&lt;p&gt;Nun haben wir endlich alle Daten, um die Karte zu erzeugen.  Zunächst
werden sowohl die Grenzverläufe als auch die Hackerspace-Koordinaten
von ihrer 3-dimensionalen polaren Darstellung aus Latitude und
Longitude in eine 2-dimensionale kartesische Projektion umgerechnet,
die für eine Kartendarstellung geeignet ist.&lt;/p&gt;
&lt;p&gt;Einzelne der Hackerspaces (die sogenannten &lt;a href="https://www.ccc.de/de/club/erfas"&gt;Erfas&lt;/a&gt;) sollen
allerdings auch auf der Karte beschriftet werden.  Diese
Beschriftungen sollen weder mit den Markern auf der Karte, noch mit
sich selbst überlappen.  Auf der alten Karte wurde die Beschriftungen
noch händisch angeordnet, um Überlappungen zu vermeiden.&lt;/p&gt;
&lt;p&gt;Für jede Beschriftung, die auf der Karte platziert werden soll, wird
zunächst der Text einmal mit der gewählten Schriftart gerendert und
ausgemessen, um den Rahmen um den Text zu bestimmen.  Aus diesem
Rahmen wird ein diskretes Set an Platzierungs-Kandidaten erzeugt.
&lt;a href="#figure2"&gt;Abbildung &lt;/a&gt; zeigt die Kandidaten, die für den CCC Berlin
generiert wurden, und rund um dessen Marker platziert wurden.&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2022-10-27-erfamap-layout-candidates.png"&gt;
    &lt;img src="https://s3lph.me/images/2022-10-27-erfamap-layout-candidates.png"
         title="Rahmen aller möglichen Beschriftungs-Positionen des CCC Berlin."
         alt="Rahmen aller möglichen Beschriftungs-Positionen des CCC Berlin."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure2" class="figureid"&gt;Abbildung 2:&lt;/a&gt;
    Rahmen aller möglichen Beschriftungs-Positionen des CCC Berlin.
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Jedem Kandidaten wird anhand von verschiedenen Faktoren eine
Gewichtung zugeordnet.  Die genaue Gewichtungen der einzelnen Faktoren
sind &lt;em&gt;magic numbers&lt;/em&gt;, die durch Ausprobieren zustandegekommen sind.
Grundsätzlich gelten aber folgende Regeln:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Bevorzuge Kandidaten, die nahe an ihrem Marker stehen.&lt;/li&gt;
&lt;li&gt;Bevorzuge Kandidaten, die weit weg von anderen Markern und anderen,
  bereits platzierten Kandidaten stehen.&lt;/li&gt;
&lt;li&gt;Bevorzuge Kandidaten, die links oder rechts von ihrem Marker stehen,
  über solche, die oberhalb oder unterhalb ihres Markers stehen.&lt;/li&gt;
&lt;li&gt;Bevorzuge Kandidaten, die rechts von ihrem Marker stehen über
  solche, die auf der linken Seite stehen.  Nahe am rechten Rand der
  Karte wird diese Regel umgekehrt.&lt;/li&gt;
&lt;li&gt;Bestrafe Kandidaten, die mit anderen Objekten überlappen, mit einem
  hohen Gewicht.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Die Platzierung der Beschriftungen findet in drei Schritten statt:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Wen es für eine Beschriftung mindestens einen Kandidaten gibt, der
   mit keinem anderen Objekt oder Kandidaten überlappt, wird dieser
   Kandidat als Platzierung festgelegt.  Dieser Schritt eliminiert die
   anderen Kandidaten für diese Beschriftung, daher werden für die
   Beschriftung anderer Marker eventuell wieder weitere Kandidaten
   «frei», die vorher mit den eliminierten Kandidaten überlappt waren.&lt;/li&gt;
&lt;li&gt;Wenn es immer noch Beschriftungen gibt, die nicht überlappungsfrei
   platziert werden können, werden diese dort platziert, wo die
   Überlappung minimal ist.&lt;/li&gt;
&lt;li&gt;Alle Gewichte werden (jetzt, wo die allermeisten Kandidaten
   eliminiert wurden) neu berechnet, und die bereits platzierten
   Beschriftungen werden anhand der neuen Gewichte optimiert.  Dadurch
   werden allfällige Überlappungen reduziert, und auch nicht
   überlappende Beschriftungen werden weiter voneinander weggeschoben,
   um die Lesbarkeit der Karte zu erhöhen.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Dieser Algorithmus ist nicht perfekt, aber er scheint lokale Optima
finden zu können, die zumindest für das menschliche Auge akzeptabel
aussehen.&lt;/p&gt;
&lt;h2 id="fazit"&gt;Fazit&lt;/h2&gt;
&lt;p&gt;Nachdem alle Beschriftungen platziert wurden, wird eine
SVG-Vektorgrafik erzeugt und in eine Datei geschrieben.  In dem SVG
wird ein &lt;em&gt;CSS-Stylesheet&lt;/em&gt; eingebettet, damit es alleinstehend
verwendet werden kann, z.B um direkt in einem Webbrowser dargestellt
zu werden, oder um weiterverarbeitet werden zu können.  Die Marker und
Beschriftungen werden zusätzlich noch mit klickbaren Links ergänzt,
die auf die Websites der einzelnen Hackerspaces weiterleiten.&lt;/p&gt;
&lt;p&gt;Aus dem SVG wird auch noch ein PNG-Bild erzeugt, zusammen mit einer
HTML &lt;em&gt;Image Map&lt;/em&gt; (mit den gleichen Links wie im SVG), sodass die Karte
auch in solchen Browsern interaktiv verwendet werden können, die nur
reduziert oder gar nicht mit SVG-Dateien umgehen können.&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2022-10-27-erfamap-new.png"&gt;
    &lt;img src="https://s3lph.me/images/2022-10-27-erfamap-new.png"
         title="Die neue, vollständig automatisch erzeugte Karte, wie sie nun auch auf ccc.de verfügbar ist."
         alt="Die neue, vollständig automatisch erzeugte Karte, wie sie nun auch auf ccc.de verfügbar ist."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure3" class="figureid"&gt;Abbildung 3:&lt;/a&gt;
    Die neue, vollständig automatisch erzeugte Karte, wie sie nun auch auf ccc.de verfügbar ist.
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Die so erzeugte neue Karte (siehe &lt;a href="#figure3"&gt;Abbildung 3&lt;/a&gt;) hat nun
auch auf &lt;a href="https://www.ccc.de/regional"&gt;ccc.de&lt;/a&gt; die bisher händisch erzeugte Karte ersetzt.
Das ganze Skript zur Generierung der Karte ist in &lt;a href="https://git.kabelsalat.ch/s3lph/erfamap"&gt;meinem
Gitea-Repo&lt;/a&gt; verfügbar.  Um das Skript verwenden zu können, musst
du allerdings die &lt;em&gt;Basic Auth&lt;/em&gt;-Zugangsdaten für das CCC MediaWiki
kennen.  Wenn du das Skript nur kurz ausprobieren willst, kannst du
aber auch auf die Beispieldaten im Repo zurückgreifen, wie im README
beschrieben.&lt;/p&gt;</content><category term="hacks"/><category term="Open Data"/><category term="Wikidata"/><category term="OpenStreetMap"/></entry><entry><title>Talk: «DNSSEC (in the year 2022)»</title><link href="https://s3lph.me/vortrag-dnssec-im-jahr-2022.html" rel="alternate"/><published>2022-09-10T00:00:00+02:00</published><updated>2022-09-10T00:00:00+02:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2022-09-10:/vortrag-dnssec-im-jahr-2022.html</id><content type="html">&lt;p&gt;I held a talk at CCC Basel titled «DNSSEC (in the year 2022) - An introduction to DNSSEC and its development in the recent years».&lt;/p&gt;
&lt;p&gt;Here's the recording of the talk (in German):&lt;/p&gt;</content><category term="talks"/><category term="Talks"/><category term="Vorträge"/><category term="DNS"/><category term="DNSSEC"/></entry><entry><title>Vortrag: «DNSSEC (im Jahr 2022)»</title><link href="https://s3lph.me/vortrag-dnssec-im-jahr-2022-de.html" rel="alternate"/><published>2022-09-10T00:00:00+02:00</published><updated>2022-09-10T00:00:00+02:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2022-09-10:/vortrag-dnssec-im-jahr-2022-de.html</id><content type="html">&lt;p&gt;Ich habe im CCC Basel einen Vortrag mit dem Titel «DNSSEC (im Jahr 2022) - Eine Einführung in DNSSEC und dessen Entwicklung in den letzten Jahren» gehalten.&lt;/p&gt;
&lt;p&gt;Hier ist die Aufzeichnung des Vortrags:&lt;/p&gt;</content><category term="talks"/><category term="Talks"/><category term="Vorträge"/><category term="DNS"/><category term="DNSSEC"/></entry><entry><title>Mein Einbürgerungverfahren in Basel - Teil 5: Das Einbürgerungsgespräch</title><link href="https://s3lph.me/einbuergerungsverfahren-in-basel-teil-5-einbuergerungsgespraech-de.html" rel="alternate"/><published>2022-08-26T17:00:00+02:00</published><updated>2022-08-26T17:00:00+02:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2022-08-26:/einbuergerungsverfahren-in-basel-teil-5-einbuergerungsgespraech-de.html</id><summary type="html">&lt;p&gt;Gestern früh hatte ich das Gespräch mit der Einbürgerungskommission
der Bürgergemeinde Basel.  Das Gespräch dauert gemäss Einladung ca. 20
Minuten, war bei mir aber schon nach ca. 15 Minuten vorbei.&lt;/p&gt;
&lt;h2 id="das-gesprach"&gt;Das Gespräch&lt;/h2&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2022-08-26-einbuergerung-05-buergerratssaal-basler-stadthaus.jpg"&gt;
    &lt;img src="https://s3lph.me/images/2022-08-26-einbuergerung-05-buergerratssaal-basler-stadthaus.jpg"
         title="Der Bürgerratssaal im Basler Stadthaus, in dem das Einbürgerungsgesprach stattfindet."
         alt="Ein kleines Zimmer.  Im Vordergrund ein Holztisch mit mehreren Stühlen. Die Wände im Hintergrund sind vollständig mit Gemälden verkleidet."&gt;
  &lt;/a&gt;
    &lt;figcaption&gt;
      &lt;a id="figure1" class="figureid"&gt;Abbildung 1:&lt;/a&gt;
      Der Bürgerratssaal im Basler Stadthaus, in dem das Einbürgerungsgesprach stattfindet.

      Quelle: Bürgergemeinde der Stadt Basel, &lt;a href="https://creativecommons.org/licenses/by-sa/4.0/deed.en"&gt;CC-BY-SA-4 …&lt;/a&gt;&lt;/figcaption&gt;&lt;/figure&gt;</summary><content type="html">&lt;p&gt;Gestern früh hatte ich das Gespräch mit der Einbürgerungskommission
der Bürgergemeinde Basel.  Das Gespräch dauert gemäss Einladung ca. 20
Minuten, war bei mir aber schon nach ca. 15 Minuten vorbei.&lt;/p&gt;
&lt;h2 id="das-gesprach"&gt;Das Gespräch&lt;/h2&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2022-08-26-einbuergerung-05-buergerratssaal-basler-stadthaus.jpg"&gt;
    &lt;img src="https://s3lph.me/images/2022-08-26-einbuergerung-05-buergerratssaal-basler-stadthaus.jpg"
         title="Der Bürgerratssaal im Basler Stadthaus, in dem das Einbürgerungsgesprach stattfindet."
         alt="Ein kleines Zimmer.  Im Vordergrund ein Holztisch mit mehreren Stühlen. Die Wände im Hintergrund sind vollständig mit Gemälden verkleidet."&gt;
  &lt;/a&gt;
    &lt;figcaption&gt;
      &lt;a id="figure1" class="figureid"&gt;Abbildung 1:&lt;/a&gt;
      Der Bürgerratssaal im Basler Stadthaus, in dem das Einbürgerungsgesprach stattfindet.

      Quelle: Bürgergemeinde der Stadt Basel, &lt;a href="https://creativecommons.org/licenses/by-sa/4.0/deed.en"&gt;CC-BY-SA-4.0&lt;/a&gt;.
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Das Gespräch findet gegenüber der gesamten, 7-köpfigen
Einbürgerungskommission im Bürgerratssaal (siehe Abbildung 1) statt.
Nach der Begrüssung durch die Vorsitzende wurde das gesamte Gespräch
allerdings von einer einzelnen Person geleitet.&lt;/p&gt;
&lt;p&gt;Die Einladung war so formuliert, dass das Gespräch «teilweise
Testcharakter» habe.  Daher war meine Erwartung, dass sich das
Gespräch z.B. aus 10 Minuten «Mündlichprüfung» und 10 Minuten Fragen
zu meinem Leben und meiner Integration zusammensetzt.&lt;/p&gt;
&lt;p&gt;Die persönlichen Fragen hielten sich aber sehr in Grenzen, nach einer
Frage zur Vereinbarkeit von Studium und Beruf und einer Frage zu
meiner Vereinsaktivität bestand das ganze restliche Gespräch aus einer
mündlichen Staatsbürgerkunde-Prüfung.&lt;/p&gt;
&lt;p&gt;Unter anderem wurden folgende Fragen gestellt:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Welche Sehenswürdigkeiten würde ich einem Besucher in Basel empfehlen?&lt;/li&gt;
&lt;li&gt;Welche Gremien treffen sich im Basler Rathaus?&lt;/li&gt;
&lt;li&gt;Welche Gerichte gibt es in Basel?&lt;/li&gt;
&lt;li&gt;Was kann ich machen, wenn auch das kantonale Appellationsgericht mir nicht Recht gibt?&lt;/li&gt;
&lt;li&gt;Welche Gerichte gibt es auf Bundesebene?&lt;/li&gt;
&lt;li&gt;Wer wählt die Bundesrichter?&lt;/li&gt;
&lt;li&gt;Was ist die vereinigte Bundesversammlung?&lt;/li&gt;
&lt;li&gt;Wer vertritt Basel-Stadt im Ständerat?&lt;/li&gt;
&lt;li&gt;Nenne 3 Nationalräte aus Basel-Stadt&lt;/li&gt;
&lt;li&gt;Welche Positionen werden noch von der vereinigten Bundesversammlung gewählt?&lt;/li&gt;
&lt;li&gt;Wer ist der Bundespräsident?&lt;/li&gt;
&lt;li&gt;Wer wird nächstes Jahr Bundespräsident? (Wusste ich nicht)&lt;/li&gt;
&lt;li&gt;Wie heisst die Exekutive der Bürgergemeinde?&lt;/li&gt;
&lt;li&gt;Durch wen wird der Bürgerrat gewählt?&lt;/li&gt;
&lt;li&gt;Welche Aufgaben hat die Bürgergemeinde?&lt;/li&gt;
&lt;li&gt;Wer war Christoph Merian?&lt;/li&gt;
&lt;li&gt;In Basel gibt es Zünfte und Ehrengesellschaften, welche gehören zu welcher Seite vom Rhein?&lt;/li&gt;
&lt;li&gt;Die kleinbasler Ehrengesellschaften organisieren eine jährliche Veranstaltung.  Wie heisst diese und wann findet sie statt?&lt;/li&gt;
&lt;li&gt;Was passiert, wenn der Termin auf einen Sonntag fällt? (Wusste ich nicht)&lt;/li&gt;
&lt;li&gt;Welche Flüsse gibt es in Basel?&lt;/li&gt;
&lt;li&gt;Welche Seen teilt sich die Schweiz mit den angrenzenden Ländern?&lt;/li&gt;
&lt;li&gt;Nenne 3 Berge.&lt;/li&gt;
&lt;li&gt;Welche politischen Rechte hat man als Schweizer Bürger?&lt;/li&gt;
&lt;li&gt;Wann ist der nächste Abstimmungstermin?&lt;/li&gt;
&lt;li&gt;Über welche Vorlagen wird am nächsten Abstimmungstermin abgestimmt?&lt;/li&gt;
&lt;li&gt;Was ist der Unterschied zwischen einem Referendum und einer Volksinitiative?&lt;/li&gt;
&lt;li&gt;Wieviele Unterschriften werden für das Zustandekommen einer Volksinitiative benötigt?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Schlussendlich wurde wieder zurück an die Vorsitzende der Kommission
übergeben, die mich nur noch darüber informierte, dass ich den
Entscheid der Einbürgerungskommission via Post erhalten werde, und
dann verabschiedete.&lt;/p&gt;
&lt;h2 id="das-ergebnis"&gt;Das Ergebnis&lt;/h2&gt;
&lt;p&gt;Bereits heute, einen Tag nach dem Gespräch, hatte ich einen Brief von
der Bürgergemeinde Basel im Briefkasten.  Die Einbürgerungskommission
hat bereits einen positiven Entscheid gefällt, und an den Bürgerrat
(die Exekutive der Bürgergemeinde) weitergereicht.&lt;/p&gt;
&lt;h2 id="das-lange-warten-30"&gt;Das Lange Warten, 3.0&lt;/h2&gt;
&lt;p&gt;Nun ist wieder warten angesagt.  Trotz dem so schnellen positiven
Entscheid der Einbürgerungskommission dauert es nun noch ca. 8-10
Monate, bis das Einbürgerungsverfahren abgeschlossen ist.&lt;/p&gt;
&lt;p&gt;Nach dem Entscheid der Bürgergemeinde geht mein Einbürgerungsgesuch
zurück an den Kanton und von dort aus weiter an den Bund, genauer ans
Staatssekretariat für Migration.  Nach dem Bundesentscheid geht es dann
wieder zurück an den Kanton zum kantonalen Einbürgerungsentscheid.&lt;/p&gt;
&lt;p&gt;Der ganze Ablauf ist übrigens im &lt;a href="https://bgbasel.ch/dam/jcr:67cbb8fc-80a9-4f30-8cd6-24e87bd521fc/Leitfaden%20Einb%C3%BCrgerung%2026.08.2019%20-%20Geb%C3%BChrensenkung%20BGBasel%2001.01.2020.pdf"&gt;Leitfaden Einbürgerung des Kantons
Basel-Stadt&lt;/a&gt; auf Seite 33 abgebildet.&lt;/p&gt;</content><category term="life"/><category term="Einbürgerung"/><category term="Schweiz"/><category term="Basel"/></entry><entry><title>Mein Einbürgerungverfahren in Basel - Teil 4: «Einbürgerungskurs»</title><link href="https://s3lph.me/einbuergerungsverfahren-in-basel-teil-4-einbuergerungskurs-de.html" rel="alternate"/><published>2022-06-18T03:42:00+02:00</published><updated>2022-06-18T03:42:00+02:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2022-06-18:/einbuergerungsverfahren-in-basel-teil-4-einbuergerungskurs-de.html</id><summary type="html">&lt;p&gt;&lt;strong&gt;Achtung&lt;/strong&gt;: Die Informationen in diesem Artikel beziehen sich auf das
Verfahren im Kanton Basel-Stadt, die Verfahren in anderen Kantonen
weichen z.T. stark ab.&lt;/p&gt;
&lt;p&gt;Im Rahmen des Einbürgerungsverfahrens muss man einen
«Einbürgerungstest» absolvieren, in dem Kenntnisse über die
politischen, historischen und geografischen Gegebenheiten der Schweiz
geprüft werden.  Wie ich bereits …&lt;/p&gt;</summary><content type="html">&lt;p&gt;&lt;strong&gt;Achtung&lt;/strong&gt;: Die Informationen in diesem Artikel beziehen sich auf das
Verfahren im Kanton Basel-Stadt, die Verfahren in anderen Kantonen
weichen z.T. stark ab.&lt;/p&gt;
&lt;p&gt;Im Rahmen des Einbürgerungsverfahrens muss man einen
«Einbürgerungstest» absolvieren, in dem Kenntnisse über die
politischen, historischen und geografischen Gegebenheiten der Schweiz
geprüft werden.  Wie ich bereits in Teil 1 erwähnt hatte, wird dieser
Test in Basel nicht wie in manchen anderen Kantonen schriftlich
durchgeführt, sondern findet mündlich im Rahmen des
Einbürgerungsgesprächs bei der Bürgergemeinde statt.&lt;/p&gt;
&lt;p&gt;Zur Vorbereitung auf diesen Test bietet die Bürgergemeinde einen
optionalen Kurs an.  Genau genommen sind es sogar zwei verschiedene
Kurse: Zum Einen gibt es den Kurs &lt;a href="https://bgbasel.ch/de/einbuergerungen/kurse/fit-fuer-basel.html"&gt;«Fit für Basel»&lt;/a&gt;, der aus 5 Lektionen
à 1h45 besteht, inklusive einem Stadtrundgang.  Zum anderen gibt es
den &lt;a href="https://bgbasel.ch/de/einbuergerungen/kurse/kompaktkurs.html"&gt;«Kompaktkurs Einbürgerung»&lt;/a&gt;, der aus nur 3 Lektionen besteht.  Die
Kurse werden je in zwei verschiedenen Varianten angeboten: Vor Ort im
Stadthaus, oder online via Zoom.  Ich hatte mich dazu entschieden, den
Kompaktkurs via Zoom zu buchen.&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2022-06-18-einbuergerung-04-basler-stadthaus.jpg"&gt;
    &lt;img src="https://s3lph.me/images/2022-06-18-einbuergerung-04-basler-stadthaus.jpg"
         title="Das Basler Stadthaus, Sitz der Bürgergemeinde Basel."
         alt="Sandstein-Frontfassade eines mehrstöckigen Gebäudes.  Über der hölzernen Eingangstüre steht &amp;quot;Stadthaus&amp;quot;"&gt;
  &lt;/a&gt;
    &lt;figcaption&gt;
      &lt;a id="figure1" class="figureid"&gt;Abbildung 1:&lt;/a&gt;
      Das Basler Stadthaus, Sitz der Bürgergemeinde Basel.

      Quelle: Bürgergemeinde der Stadt Basel, &lt;a href="https://creativecommons.org/licenses/by-sa/4.0/deed.en"&gt;CC-BY-SA-4.0&lt;/a&gt;.
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h2 id="erste-lektion-die-schweiz"&gt;Erste Lektion: Die Schweiz&lt;/h2&gt;
&lt;p&gt;Im ersten Kurstermin ging es um die Schweiz als Bundesstaat und
diverser Kennzahlen auf Bundesebene.  Es wurde gleich vom Anfang an,
und auch während dem Kurs immer wieder, darauf hingewiesen, dass der
Kurs ein gutes Stück mehr ins Detail ginge, als wir für das
Einbürgerungsgespräch wissen müssten.  Alles, was für die Einbürgerung
relevant sei, erhielten wir jeweils im Anhang der Zoom-Einladungen.&lt;/p&gt;
&lt;p&gt;Die relevanten Fakten dieser Lektion waren:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Bevölkerungszahl und Fläche der Schweiz&lt;/li&gt;
&lt;li&gt;Nennung aller Landessprachen und Nachbarländer&lt;/li&gt;
&lt;li&gt;Nennung von 4 Flüssen und 4 Seen&lt;/li&gt;
&lt;li&gt;Die drei Gewalten und deren wichtigsten Organe auf Bundesebene&lt;ul&gt;
&lt;li&gt;National- und Ständerat inkl. Anzahl Mitglieder&lt;/li&gt;
&lt;li&gt;Nennung aller Bundesräte und welchen Departementen diese vorstehen&lt;/li&gt;
&lt;li&gt;Nennung der Bundesgerichte und deren Standorte&lt;/li&gt;
&lt;li&gt;Wer diese Organe wählt&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Aus meiner Sicht ist das erforderliche Wissen auf Bundesebene damit
recht überschaubar, allerdings bin ich auch in der Schweiz (wenn auch
in einem anderen Kanton) zur Schule gegangen, und habe auch vor meiner
Auswanderung in de Schweiz in einem demokratischen Staat mit
Gewaltenteilung gelebt, daher waren diese Konzepte nicht wirklich neu
für mich.&lt;/p&gt;
&lt;p&gt;Ich hatte von dem Kurs monotone, vorlesungsartige Lektionen erwartet.
Tatsächlich war der Kurs aber eine recht kleine Runde (ca. 15
Personen) und recht locker und vor allem interaktiv gestaltet.  Neben
den häufigen Fragen in die Runde, ob das schon jemand beantworten
könne, warf der Kursleiter auch immer wieder ein paar humorvolle
Kommentare mit ein.  So kommentierte er z.B. das
&lt;a href="https://www.admin.ch/gov/de/start/bundesrat/bilder-und-reden-des-bundesrats/offizielle-bundesratsbilder/_jcr_content/par/imagegallery_2121685220/items/22_1640265161352/image.imagespooler.jpg/1640687246152/1024.2000/BR_Gruppenbild_2022_web.png"&gt;Bundesratsfoto&lt;/a&gt; mit «Das ist nicht etwa eine missglückte
Photoshop-Arbeit von mir, das ist das offizielle Bundesratsfoto».&lt;/p&gt;
&lt;p&gt;Besonders beeindruckt hat mich, das bei den Slides zu
Bevölkerungswachstum und Migration die Hintergründe zu den
Schwankungen erläutert wurden, z.B. warum die Bevölkerungszahl seit
1900 während zwei Phasen abgenommen hatte (einmal die Spanische
Grippe, einmal eine Wirtschaftsrezession, während der Gastarbeiter
nicht länger beschäftigt wurden und in ihre Heimat zurückkehrten).&lt;/p&gt;
&lt;p&gt;Auch wurde z.B. beim Vergleich des Ausländeranteils der Schweizer
Bevölkerung mit anderen Ländern darauf hingewiesen, dass diese
Vergleiche mit Vorsicht zu geniessen seien, und dass es eher sinnvoll
wäre, die Migrationsströme zu vergleichen, da sich die Rechtslage zum
Erhalt der jeweiligen Staatsbürgerschaften zwischen den Ländern teils
stark unterscheide und manche Ländern z.B. im Inland geborenen Kindern
ausländischer Eltern bei der Geburt automatisch die jeweilige
Staatsbürgerschaft verleihen.&lt;/p&gt;
&lt;p&gt;Auch historische Ereignisse wurden entsprechend eingeordnet.  So wurde
z.B. erklärt, dass das Zweikammernsystem aus National- und Ständerat
eine Kompromisslösung ist, die als Konsequenz des Sonderbundskrieges
von 1847 entstanden ist, und von den damals noch jungen Vereinigten
Staaten von Amerika abgekupfert wurde.  Neben solchen hinstorischen
Einordnungen gab es allerdings auch Fakten, die ich persönlich eher in
die Kategorie «unnützes Wissen» einordnen würde.  So z.B., dass es
seit 1848 (Grundüngs des Schweizer Bundesstaates) genau ein einziges
Bundesgerichtsurteil gab, welches auf rätoromanisch verfasst wurde.&lt;/p&gt;
&lt;h2 id="zweite-lektion-der-kanton-basel-stadt"&gt;Zweite Lektion: Der Kanton Basel-Stadt&lt;/h2&gt;
&lt;p&gt;Der zweite Kurstermin startete gleich schon mit 35 Minuten
Verspätung - die Bürgergemeinde hatte einen falschen Zoom-Link an die
Teilnehmenden geschickt, und dann alle Teilnehmenden angerufen, um die
richtigen Zugangsdaten telefonisch mitzuteilen.  Zwar war die
Zoom-Einladung an sich fehlerhaft, aber auch dieses Mal waren wieder
Dokumente mit allen Information, die wir für das Einbürgerungsgespräch
wissen sollten, angehängt:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Anzahl Kantone in der Schweiz&lt;/li&gt;
&lt;li&gt;Bevölkerungszahl und Fläche des Kantons Basel-Stadt&lt;/li&gt;
&lt;li&gt;Die drei Gemeinden in Basel-Stadt, Nachbargemeinden und -länder&lt;/li&gt;
&lt;li&gt;Flüsse in Basel-Stadt, höchstgelegener Punkt im Kanton&lt;/li&gt;
&lt;li&gt;Die drei Gewalten und ihre wichtigsten Organe im Kanton Basel-Stadt:&lt;ul&gt;
&lt;li&gt;Grosser Rat und Anzahl Mitglieder&lt;/li&gt;
&lt;li&gt;Regierungsrat inkl. Nennung aller Mitglieder und deren Departemente&lt;/li&gt;
&lt;li&gt;Alle Basler Abgeordneten im National- (5) und Ständerat (1)&lt;/li&gt;
&lt;li&gt;Die kantonalen Gerichte und ihre Standorte&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Historische Daten:&lt;ul&gt;
&lt;li&gt;1291: Gründung der Eidgenossenschaft&lt;/li&gt;
&lt;li&gt;1501: Beitritt Basels zur Eidgenossenschaft&lt;/li&gt;
&lt;li&gt;1833: Trennung in Basel-Stadt und Basel-Landschaft&lt;/li&gt;
&lt;li&gt;1971: Einführung des Frauenwahl- und stimmrechts auf Bundesebene&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Auch dieses Mal wurde wieder deutlich mehr ins Detail und in die
Geschichte gegangen, als wir für das Einbürgerungsgespräch wissen
müssen.  So sind wir z.B. die Geschichte der alten Eidgenossenschaft
mit u.a. den Beitrittsdaten der "wichtigsten" Kantone durchgegangen.
Auch sind wir auf einer unbeschrifteten Schweiz-Karte alle 26 Kantone
mitsamt Hauptorten durchgegangen, was doch einige Zeit gedauert hatte.&lt;/p&gt;
&lt;p&gt;Neben einer doch sehr fakten- und zahlenlastigen Lektion gab es doch
noch einige historische Anekdoten zu hören.  So zum Beispiel die
&lt;a href="https://www.staatskanzlei.bs.ch/rathaus/kunst.html#page_section3_section2"&gt;Geschichte der Justitia-Statue auf dem Basler Rathaus&lt;/a&gt;.  Diese war
nämlich ursprünglich eine Marienstatue, die im Rahmen der Reformation
aus dem Basler Münster entfernt worden war.  Als der Entscheid gefällt
wurde, eine Justitia-Statue aufzustellen, wurde kurzerhand einfach
diese Marienstatue genommen, und Jesuskind und Zepter durch Schwert
und Waage ersetzt.  Daher trägt die «Basler Justita» auch eine Krone
statt Augenbinde, und ist in rot und blau gewandet. (Nein, die Farben
stehen nicht wie sonst in Basel für den FCB!)&lt;/p&gt;
&lt;h2 id="dritte-lektion-die-stadt-basel"&gt;Dritte Lektion: Die Stadt Basel&lt;/h2&gt;
&lt;p&gt;Im dritten Kurstermin ging es primär um die Stadt Basel.  Der Kurs
begann mit einer Erklärung, dass die politischen
Organisationsstrukturen in Basel ein Ausnahmefall seien: Denn Basel
verfügt nicht über eine eigenständige Stadtregierung.  So gibt es
keine Einwohnergemeindeversammlung, und keine Bürgermeister*innen.  Diese
Rollen werden von den kantonalen Gremien wahrgenommen, also vom
Grossen Rat und dem Regierungsrat.  Die anderen beiden Gemeinden im
Kanton Basel-Stadt haben jedoch diese Organe.  Andersherum ist jedoch
die Bürgergemeinde der Stadt Basel eine der wenigen Bürgergemeinden
mit einem Parlament auf Gemeindeebene.&lt;/p&gt;
&lt;p&gt;Neben dieser «Kuriosität» gab es auch dieses Mal wieder eine lange
Liste an Informationen, die wir für das Einbürgerungsgespräch wissen
sollten, darunter:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Die Bürgergemeinde Basel&lt;ul&gt;
&lt;li&gt;Tagungsort und Grösse des Bürgergemeinderats (Legislative)&lt;/li&gt;
&lt;li&gt;Grösse des Bürgerrats (Exekutive), aber ohne die Mitglieder nennen zu können&lt;/li&gt;
&lt;li&gt;Aufgaben der Bürgergemeinde&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Brauchtum&lt;ul&gt;
&lt;li&gt;Kenntnis von Basler Bräuchen (Vogel Gryff, Fasnacht)&lt;/li&gt;
&lt;li&gt;Nennung einiger Zünfte und der drei Ehrengesellschaften&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Geschichte der Stadt Basel&lt;ul&gt;
&lt;li&gt;Besiedelung durch Kelten und Römer&lt;/li&gt;
&lt;li&gt;Das &lt;a href="https://de.wikipedia.org/wiki/Basler_Erdbeben_1356"&gt;Basler Erdbeben 1356&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Gründung der Universität und Erlangung des Messerechts&lt;/li&gt;
&lt;li&gt;Bau der Mittleren Rheinbrücke&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Wirtschaft in Basel (Pharma, Messen, Tourismus)&lt;/li&gt;
&lt;li&gt;Kenntnis der politischen Rechte (Initiativen und Referenden)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Diese Informationen sind wir - wie auch in den vorigen beiden
Kursterminen - ziemlich detailliert durchgegangen, mitsamt
historischen Anekdoten und Einordnung in das heutige Stadtbild.&lt;/p&gt;
&lt;p&gt;Alles in allem kann ich sagen, dass der Kurs sich mehr als gelohnt
hat.  Nicht nur wurde uns mitgeteilt, welche Informationen wir für das
Einbürgerungsgespräch wissen müssen, sondern es wurden auch sehr
interessante Einblicke in die Geschichte der Schweiz und von Basel
gegeben, die für mich zumindest teilweise neu waren.  Allerdings frage
ich mich, da ich ja den «Kompaktkurs» besucht hatte, wieviel mehr ins
Detail wohl der «grosse» Kurs mit 5 Lektionen gegangen wäre.&lt;/p&gt;
&lt;h2 id="das-warten-geht-weiter"&gt;Das Warten Geht Weiter&lt;/h2&gt;
&lt;p&gt;Im letzten Beitrag hatte ich schon angekündigt, dass wieder Warten
angesagt sei.  Der Kurs war nur ein kleiner Unterbruch dieser
mehrmonatigen Frist, bis die Bürgergemeinde mich zum
Einbürgerungsgespräch einlädt.&lt;/p&gt;</content><category term="life"/><category term="Einbürgerung"/><category term="Schweiz"/><category term="Basel"/></entry><entry><title>Talk: «Post Mortem»</title><link href="https://s3lph.me/vortrag-post-mortem.html" rel="alternate"/><published>2022-05-14T00:00:00+02:00</published><updated>2022-05-14T00:00:00+02:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2022-05-14:/vortrag-post-mortem.html</id><content type="html">&lt;p&gt;I held a talk at CCC Basel titled «Post Mortem: Lessons Learned from the Death of a Sysadmin».&lt;/p&gt;
&lt;p&gt;Here's the recording of the talk (in German):&lt;/p&gt;</content><category term="talks"/><category term="Talks"/><category term="Vorträge"/></entry><entry><title>Vortrag: «Post Mortem»</title><link href="https://s3lph.me/vortrag-post-mortem-de.html" rel="alternate"/><published>2022-05-14T00:00:00+02:00</published><updated>2022-05-14T00:00:00+02:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2022-05-14:/vortrag-post-mortem-de.html</id><content type="html">&lt;p&gt;Ich habe im CCC Basel einen Vortrag namens «Post Mortem: Lessons Learned aus dem Todesfall eines Serveradmins» gehalten.&lt;/p&gt;
&lt;p&gt;Hier ist die Aufzeichnung des Vortrags:&lt;/p&gt;</content><category term="talks"/><category term="Talks"/><category term="Vorträge"/></entry><entry><title>Mein Einbürgerungverfahren in Basel - Teil 3: Telefonisches Erhebungsgespräch</title><link href="https://s3lph.me/einbuergerungsverfahren-in-basel-teil-3-erhebungsgespraech-de.html" rel="alternate"/><published>2022-03-02T03:42:00+01:00</published><updated>2022-03-02T03:42:00+01:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2022-03-02:/einbuergerungsverfahren-in-basel-teil-3-erhebungsgespraech-de.html</id><summary type="html">&lt;p&gt;&lt;strong&gt;Achtung&lt;/strong&gt;: Die Informationen in diesem Artikel beziehen sich auf das
Verfahren im Kanton Basel-Stadt, die Verfahren in anderen Kantonen
weichen z.T. stark ab.&lt;/p&gt;
&lt;p&gt;Letzte Woche hatte ich von Migrationsamt Basel-Stadt einen Brief
erhalten, mit der Bitte, mich telefonisch beim Migrationsamt zu
melden, da noch ein paar offene Fragen bestünden …&lt;/p&gt;</summary><content type="html">&lt;p&gt;&lt;strong&gt;Achtung&lt;/strong&gt;: Die Informationen in diesem Artikel beziehen sich auf das
Verfahren im Kanton Basel-Stadt, die Verfahren in anderen Kantonen
weichen z.T. stark ab.&lt;/p&gt;
&lt;p&gt;Letzte Woche hatte ich von Migrationsamt Basel-Stadt einen Brief
erhalten, mit der Bitte, mich telefonisch beim Migrationsamt zu
melden, da noch ein paar offene Fragen bestünden.&lt;/p&gt;
&lt;h2 id="telefonisches-erhebungsgesprach"&gt;Telefonisches Erhebungsgespräch&lt;/h2&gt;
&lt;p&gt;Gestern rief ich bei der Sachbearbeiterin meines Einbürgerungsgesuchs
an.  Dabei erfuhr ich auch, dass es sich bei diesem Telefonat um das
angekündigte «Erhebungsgespräch» handelte.&lt;/p&gt;
&lt;p&gt;Die meisten Fragen, die mir im Lauf des Gesprächs gestellt wurden,
bezogen sich auf meine Angaben, die ich auf den eingereichten
Formularen gemacht hatte.  Darunter waren aber auch einige Fragen, die
mir eher seltsam und übertrieben vorkamen.  Zum Beispiel musste ich
angeben, in welchem Stockwerk meine Wohnung liegt, und wie viele Zimmer
sie hat.  Auch nach den Berufen und Staatsangehörigen meiner
Familienangehörigen wurde gefragt.&lt;/p&gt;
&lt;p&gt;Zusätzlich wurde ich noch darum gebeten, einen Lebenslauf einzusenden.
Dies hatte mich ebenfalls etwas verwirrt, da ich bereits in den
Gesuchsformularen deutlich mehr Angaben machen musste, als in einem
normalen Lebenslauf üblich sind.  Auf meine Rückfrage hin wurde mir
jedoch versichert, dass tatsächlich ein normaler Lebenslauf gefragt
war, wie er bei einer Stellenbewerbung üblich ist.&lt;/p&gt;
&lt;h2 id="befragung-von-referenzpersonen"&gt;Befragung von Referenzpersonen&lt;/h2&gt;
&lt;p&gt;Am Nachmittag nach  dem Erhebungsgespräch erzählte mir  dann noch mein
Chef, den ich auf dem Einbürgerungsgesuch als Referenzperson angegeben
hatte,  dass er  kurz nach  dem Gespräch  vom Migrationsamt  angerufen
wurde.  Ihm wurden ebenfalls einige Fragen gestellt, darunter ob:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;er es befürwortet, dass ich eingebürgert werde&lt;/li&gt;
&lt;li&gt;ich gut in der Schweiz integriert bin&lt;/li&gt;
&lt;li&gt;ich Kontakt zu Schweizer Bürger*innen pflege&lt;/li&gt;
&lt;li&gt;ich mit lokalem Brauchtum vertraut bin&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="das-lange-warten-20"&gt;Das Lange Warten, 2.0&lt;/h2&gt;
&lt;p&gt;Nun ist wieder warten angesagt.  Der nächste Schritt in meinem
Einbürgerungsverfahren, das Einbürgerungsgespräch bei der
Bürgergemeinde Basel, wird anscheinend erst in mindestens 6 Monaten
stattfinden.  Davor werde ich noch einen Einbürgerungskurs besuchen,
der ebenfalls von der Bürgergemeinde veranstaltet wird.&lt;/p&gt;</content><category term="life"/><category term="Einbürgerung"/><category term="Schweiz"/><category term="Basel"/></entry><entry><title>Restoring a Commodore CBM 3016</title><link href="https://s3lph.me/restoring-a-commodore-cbm-3016.html" rel="alternate"/><published>2022-02-09T00:00:00+01:00</published><updated>2022-02-09T00:00:00+01:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2022-02-09:/restoring-a-commodore-cbm-3016.html</id><summary type="html">&lt;p&gt;We recently got our hands on a &lt;a href="https://www.c64-wiki.com/wiki/PET_2001"&gt;Commodore CBM 3016&lt;/a&gt;, built
between 1978 and 1980.  Unfortunately, as we attempted to power it up, we were
greeted with a screen full of random characters:&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2022-02-09-commodore-cbm-scrambled-screen.jpg"&gt;
    &lt;img src="https://s3lph.me/images/2022-02-09-commodore-cbm-scrambled-screen.jpg"
         title="CBM 3016 screen filled with random characters."
         alt="CBM 3016 screen filled with random characters."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure1" class="figureid"&gt;Figure 1:&lt;/a&gt;
    CBM 3016 screen filled with random characters.
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;A short web search told us that a …&lt;/p&gt;</summary><content type="html">&lt;p&gt;We recently got our hands on a &lt;a href="https://www.c64-wiki.com/wiki/PET_2001"&gt;Commodore CBM 3016&lt;/a&gt;, built
between 1978 and 1980.  Unfortunately, as we attempted to power it up, we were
greeted with a screen full of random characters:&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2022-02-09-commodore-cbm-scrambled-screen.jpg"&gt;
    &lt;img src="https://s3lph.me/images/2022-02-09-commodore-cbm-scrambled-screen.jpg"
         title="CBM 3016 screen filled with random characters."
         alt="CBM 3016 screen filled with random characters."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure1" class="figureid"&gt;Figure 1:&lt;/a&gt;
    CBM 3016 screen filled with random characters.
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;A short web search told us that a common cause for this issue are bad ROM modules.&lt;/p&gt;
&lt;h2 id="understanding-the-cbm-memory-layout-and-rom"&gt;Understanding the CBM Memory Layout and ROM&lt;/h2&gt;
&lt;p&gt;The &lt;a href="https://en.wikipedia.org/wiki/MOS_Technology_6502"&gt;MOS 6502&lt;/a&gt; CPU used in the PET / CBM series has a 8 bit data bus and a 16 bit address bus. Schematics found online reveal that the uppermost 4 of those 16 bits are used to determine which device to talk to: The entire lower half of the address space (&lt;code&gt;$0000:$7fff&lt;/code&gt; are mapped to DRAM, though on our model 3016 with 16KiB RAM, only the area &lt;code&gt;$0000:$3fff&lt;/code&gt; can actually be used. Next follows the video buffer at &lt;code&gt;$8000:$8fff&lt;/code&gt;, and after that follow different 4KiB ROM chips at &lt;code&gt;$9000:9fff&lt;/code&gt; through &lt;code&gt;$f000:$ffff&lt;/code&gt;.  Again, only part of this is used; the address space &lt;code&gt;$9000:$bfff&lt;/code&gt; maps to empty sockets which can be populated with software ROMs by the user.&lt;/p&gt;
&lt;p&gt;The actual operating system of the CBM is stored on 4 different ROMs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;$c000:$cfff&lt;/code&gt;: BASIC editor/interpreter/runtime, part 1&lt;/li&gt;
&lt;li&gt;&lt;code&gt;$d000:$dfff&lt;/code&gt;: BASIC editor/interpreter/runtime, part 2&lt;/li&gt;
&lt;li&gt;&lt;code&gt;$e000:$e7ff&lt;/code&gt;: IO processing routines.  This is actually a 2KiB ROM, the upper half is mapped to actual hardware IO (e.g. the keyboard).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;$f000:$ffff&lt;/code&gt;: The KERNAL ("kernel" with a typo that kept on living).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There also is an additional ROM, which stores the character set that can be displayed on the screen, but this ROM is not attached to the MOS 6502 address bus.  Instead it is used directly by the screen rendering part of the PCB.&lt;/p&gt;
&lt;p&gt;We removed these ROMs and dumped each of them using the MiniPRO TL866A programmer:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="n"&gt;minipro&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;yp&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;27&lt;/span&gt;&lt;span class="n"&gt;C32A&lt;/span&gt;&lt;span class="nv"&gt;@DIP24&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;basic1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bin&lt;/span&gt;
&lt;span class="n"&gt;minipro&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;yp&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;27&lt;/span&gt;&lt;span class="n"&gt;C32A&lt;/span&gt;&lt;span class="nv"&gt;@DIP24&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;basic2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bin&lt;/span&gt;
&lt;span class="n"&gt;minipro&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;yp&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;27&lt;/span&gt;&lt;span class="n"&gt;C32A&lt;/span&gt;&lt;span class="nv"&gt;@DIP24&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;kernal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bin&lt;/span&gt;
&lt;span class="n"&gt;minipro&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;yp&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;M2716&lt;/span&gt;&lt;span class="nv"&gt;@DIP24&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bin&lt;/span&gt;
&lt;span class="n"&gt;minipro&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;yp&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;M2716&lt;/span&gt;&lt;span class="nv"&gt;@DIP24&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bin&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Multiple versions of the KERNAL+BASIC system have been spotted in the wild, and we did not know which version was used by our CBM, so we didn't yet know what these dumps were actually supposed to look like.  Fortunately, we managed to find &lt;a href="http://www.zimmers.net/anonftp/pub/cbm/firmware/computers/pet/"&gt;dumps of most of these versions online&lt;/a&gt;, and only one of those matched our dumps: The BASIC 2.0 version.&lt;/p&gt;
&lt;p&gt;At least most of our dumps matched.  The IO ROM and the first BASIC ROM did not even closely resemble the dumps we had found online.  Additionally, when we attempted to dump the ROMs for a second time, these two ROMs yielded totally different data.  After repeating this for a few times, we concluded that these two ROMs were broken completely.&lt;/p&gt;
&lt;h2 id="creating-new-roms"&gt;Creating New ROMs&lt;/h2&gt;
&lt;p&gt;We managed to find some pinout-compatible EPROMs we wanted to use as a replacement.  However, after erasing them with our UV eraser, it turned out that our programmer was not capable of producing the 25V required for programming these chips.  We fiddled around with an external PSU, and did achieve some success (as in: we managed to write some random bits), but we never managed to complete a full write sequence.&lt;/p&gt;
&lt;p&gt;We started looking for alternatives and found multiple rails of &lt;a href="https://ww1.microchip.com/downloads/en/DeviceDoc/doc0189.pdf"&gt;AT27C040&lt;/a&gt; PLCC-packaged one-time-programmable ROMs.  These PROMs feature a whole 512 KiB of storage.  Our plan was to use the lowermost 2 resp. 4 KiB and after programming short the excess address pins to GND.  I designed a small adapter PCB from PLCC32 to DIP24, suitable for replacing both a 2K and a 4K ROM.&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2022-02-09-commodore-cbm-plccdip-adapter-schematic.png"&gt;
    &lt;img src="https://s3lph.me/images/2022-02-09-commodore-cbm-plccdip-adapter-schematic.png"
         title="Schematic of the PLCC32 to DIP24 adapter PCB designed for using an AT27C040 ROM as a replacement for the CBM's broken ROMs."
         alt="Schematic of the PLCC32 to DIP24 adapter PCB designed for using an AT27C040 ROM as a replacement for the CBM's broken ROMs."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure2" class="figureid"&gt;Figure 2:&lt;/a&gt;
    Schematic of the PLCC32 to DIP24 adapter PCB designed for using an AT27C040 ROM as a replacement for the CBM's broken ROMs.
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;The solder bridge in this schematic is used to choose between the 2K and 4K version; with 4K the OE (Output Enable) pin of the ROM is always pulled low, for the 2K version it is connected to A11 (the 12th address bit): when this bit is HIGH, the output is disabled and the pins are kept floating as to not disturb the communcation between the CPU and the IO hardware mapped to the upper half of the &lt;code&gt;$e000:$efff&lt;/code&gt; address space.&lt;/p&gt;
&lt;p&gt;Though, in the end we used the ugly but easy solution of soldering some short wires between the ROM and a DIP socket, rather than manufacturing a full-blown PCB.  And indeed, once we had put our replacement ROMs into their sockets, we did at least achieve some progress:  The screen was still showing random characters on startup, but after a few seconds the entire screen was cleared, and then nothing happened anymore.  So it appeared the CPU was doing &lt;em&gt;something&lt;/em&gt;, but apparently not the right thing.&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2022-02-09-commodore-cbm-plccdip-roms.jpg"&gt;
    &lt;img src="https://s3lph.me/images/2022-02-09-commodore-cbm-plccdip-roms.jpg"
         title="The replacement ROMs placed in their sockets."
         alt="The replacement ROMs placed in their sockets."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure3" class="figureid"&gt;Figure 3:&lt;/a&gt;
    The replacement ROMs placed in their sockets.
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h2 id="hunting-down-cpu-misbehavior"&gt;Hunting Down CPU Misbehavior&lt;/h2&gt;
&lt;p&gt;In order to figure out what the CPU was doing, I attached a logic analyzer to the address bus pins and recorded every address on the bus between startup and the screen blanking.  One thing that became apparent immediately was that all communication on the address bus stopped after the screen was blanked.  As the MOS 6502 CPU has to at least fetch an instruction every 1-6 cycles, it appeared as if the CPU had halted, which under normal conditions is only possible when it's executing a &lt;a href="https://en.wikipedia.org/wiki/Halt_and_Catch_Fire_(computing)"&gt;HCF&lt;/a&gt;-style illegal opcode.&lt;/p&gt;
&lt;p&gt;So I took a closer look at the instructions the CPU was loading right before freezing.  I used the &lt;a href="https://www.floodgap.com/retrotech/xa/#dxa"&gt;dxa65&lt;/a&gt; disassembler to turn the ROM dumps into 6502 assembly mnemonics in order to understand which instructions were located at which addresses.  And indeed, the last instruction executed before freezing, read from address &lt;code&gt;$f4e0&lt;/code&gt;, was &lt;code&gt;$d2&lt;/code&gt;, &lt;a href="https://www.masswerk.at/6502/6502_instruction_set.html#JAM"&gt;an illegal opcode that halts the CPU&lt;/a&gt;. The disassembly revealed that this was actually not supposed to be an instruction, but a zeropage address passed as an argument to a previous instruction. Going a few cycles backwards, I found something that seemed promising: A &lt;a href="https://www.masswerk.at/6502/6502_instruction_set.html#RTS"&gt;&lt;code&gt;rts&lt;/code&gt;&lt;/a&gt; (return from subroutine) instruction was loaded, followed by two accesses in the stack page at &lt;code&gt;$01fe:$01ff&lt;/code&gt; (popping the return address from the stack), and then the next instruction was loaded from &lt;code&gt;$f4d8&lt;/code&gt;.  The odd part here is that a &lt;code&gt;rts&lt;/code&gt; should only ever jump back to an address preceded by a corresponding &lt;a href="https://www.masswerk.at/6502/6502_instruction_set.html#JSR"&gt;&lt;code&gt;jsr&lt;/code&gt;&lt;/a&gt; (jump to subroutine) instruction, which was absent here.&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2022-02-09-commodore-cbm-logic-analyzer-combined.jpg"&gt;
    &lt;img src="https://s3lph.me/images/2022-02-09-commodore-cbm-logic-analyzer-combined.jpg"
         title="Left: A logic analyzer attached to the address bus pins and the Φ₂ system clock. Right: Screenshot of the logic analyzer software, showing the decoded memory addresses at the top."
         alt="Left: A logic analyzer attached to the address bus pins and the Φ₂ system clock. Right: Screenshot of the logic analyzer software, showing the decoded m\
emory addresses at the top."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure4" class="figureid"&gt;Figure 4:&lt;/a&gt;
    Left: A logic analyzer attached to the address bus pins and the Φ₂ system clock.
    Right: Screenshot of the logic analyzer software, showing the decoded memory addresses at the top.
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;So clearly the CPU was returning to a wrong address.  At this point I started to suspect faulty RAM.  To find out more I searched even further back until I found the &lt;code&gt;jsr&lt;/code&gt; instruction — at address &lt;code&gt;$fcd5&lt;/code&gt;. So, according to the specification of the &lt;code&gt;jsr&lt;/code&gt; instruction, the real return address pushed to the stack had to be &lt;code&gt;$fcd7&lt;/code&gt; (address of the &lt;code&gt;jsr&lt;/code&gt; + 2).  When we compare this to the address actually popped from the stack, &lt;code&gt;$f6d7&lt;/code&gt; (actual return address - 1), we can see that these addresses differ in exactly one bit, which confirmed my suspicion of faulty RAM:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;$fcd7 = 1111 1100 1101 0111
$f4d7 = 1111 0100 1101 0111
             ^
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;h2 id="finding-the-faulty-ram"&gt;Finding the Faulty RAM&lt;/h2&gt;
&lt;p&gt;In order to figure out which part of the RAM was broken, we first need to understand the DRAM chips' physical layout on the PCB.&lt;/p&gt;
&lt;p&gt;The CBM 3016 series' DRAM consists of 16 MOSTEK MK4108 modules, each in a DIP16 package, organized in 2 groups of 8.  Reading the &lt;a href="https://usermanual.wiki/Document/1979MostekMicrocomputerProductsDataBook.313031759/view"&gt;datasheet of the MK4108/4116&lt;/a&gt; (p.140ff), reveals that each one stores 8K single bits.  Probing around on the PCB with a multimeter revealed the following memory layout:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;               D0    D1    D2    D3    D4    D5    D6    D7
               __    __    __    __    __    __    __    __
              |  |  |  |  |  |  |  |  |  |  |  |  |  |  |  |
              |  |  |  |  |  |  |\/|  |  |  |  |  |  |  |  |
$0000:$1fff   |  |  |  |  |  |  |/\|  |  |  |  |  |  |  |  |
              |__|  |__|  |__|  |__|  |__|  |__|  |__|  |__|

               __    __    __    __    __    __    __    __
              |  |  |  |  |  |  |  |  |  |  |  |  |  |  |  |
              |  |  |  |  |  |  |  |  |  |  |  |  |  |  |  |
$2000:$3fff   |  |  |  |  |  |  |  |  |  |  |  |  |  |  |  |
              |__|  |__|  |__|  |__|  |__|  |__|  |__|  |__|


                            VVVV  FRONT VVVV
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Since the flipped bit was in the 4th position (counting from the LSB of the upper byte), and the stack (where the wrong return address was read from) is at &lt;code&gt;$0100:$01ff&lt;/code&gt;, the faulty module should be the D3 module in the upper row, marked with an X.  To validate this, we swapped the module with the D3 module in the front row - luckily these DIP modules are socketed.  Lo and behold, the computer started and we were presented with the BASIC interpreter.  Note that instead of the usual ~15K memory, only 7K are reported as free and usable:&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2022-02-09-commodore-cbm-basic-interpreter-8k.jpg"&gt;
    &lt;img src="https://s3lph.me/images/2022-02-09-commodore-cbm-basic-interpreter-8k.jpg"
         title="The CBM 3016 showing the BASIC interpreter.  7K of memory are reported as free, instead of the expected 15K."
         alt="The CBM 3016 showing the BASIC interpreter.  7K of memory are reported as free, instead of the expected 15K."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure5" class="figureid"&gt;Figure 5:&lt;/a&gt;
    The CBM 3016 showing the BASIC interpreter.  7K of memory are reported as free, instead of the expected 15K.
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Now that the system had at least some working memory, I decided to write a small memtest program.  I ended up writing this directly in 6502 assembly, written to a ROM plugged into the &lt;code&gt;$f000:$ffff&lt;/code&gt; socket normally occupied by the KERNAL ROM, due to multiple reasons:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The KERNAL actively uses the memory region &lt;code&gt;$0000:$2000&lt;/code&gt;, so loading the memtest program from another location after the KERNAL has been loaded would corrupt this memory region.&lt;/li&gt;
&lt;li&gt;An entire memory page is occupied by the stack, but this page should be tested as well.  Writing in assembly allowed me to avoid implicitly using the stack, or any other implicit memory allocations.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The memtest program only uses 7 bytes of zeropage memory to keep its state, the entire remainder of the memory is tested.  For more efficient testing (and to not waste tons of single-write PROMs) I also ended up wrapping a MOS 6502 emulator with the CBM 3016's memory map and simulated memory failures.  The memtest program can be found on &lt;a href="https://gitlab.com/s3lph/cbm3001-memtest"&gt;Gitlab&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Flashing the memtest16k image to a PROM and booting it on our CBM 3016 revealed memory errors for every single address in the upper half of the DRAM address space, &lt;code&gt;$2000:$3fff&lt;/code&gt;. Most of these were indeed caused by the faulty memory module we had already identified earlier, however, there have also been some additional reports caused by a different module, as they had happened at a different bit.&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2022-02-09-commodore-cbm-memtest.jpg"&gt;
    &lt;img src="https://s3lph.me/images/2022-02-09-commodore-cbm-memtest.jpg"
         title="Memtest running on the CBM 3016 and reporting errors in the entire upper half of the DRAM address space."
         alt="Memtest running on the CBM 3016 and reporting errors in the entire upper half of the DRAM address space."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure6" class="figureid"&gt;Figure 6:&lt;/a&gt;
    Memtest running on the CBM 3016 and reporting errors in the entire upper half of the DRAM address space.  Each entry consists of an address, the byte written to said address and the wrong byte that was read back from RAM.
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h2 id="replacing-defective-ram-modules"&gt;Replacing Defective RAM Modules&lt;/h2&gt;
&lt;p&gt;We assumed it would be quite hard to find compatible memory modules.  However, in our hackerspace I found a Commodore C64 which had already been scavenged for spare parts, so there were no hard feelings in taking its memory as well.  The C64 has 8 memory modules, each holding 64Kib. These modules, &lt;a href="https://www.datasheets360.com/pdf/-1862440600650638492"&gt;Micron MT4264-15&lt;/a&gt; were &lt;em&gt;mostly&lt;/em&gt; pin-compatible to the MK4108/4116; they have an additional address pin, but fewer power pins (they run on 5V only, rather than the MK4108's triplet of -5V, +5V and +12V).  Making an adapter socket was trivial, the hardest part was desoldering the DIP24 package from the C64 PCB; instead of using DIP sockets like in the PET/CBM series, the C64 has its RAM soldered directly to the PCB.&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2022-02-09-commodore-cbm-ram-replacement-combined.jpg"&gt;
    &lt;img src="https://s3lph.me/images/2022-02-09-commodore-cbm-ram-replacement-combined.jpg"
         title="Left: The pin mapping adapter schematic for using MT4264 chips in a socket designed for MK4108/4116 DRAM. Right: The replacement memory module on its adapter socket."
         alt="Left: The pin mapping adapter schematic for using MT4264 chips in a socket designed for MK4108/4116 DRAM. Right: The replacement memory module on its ad\
apter socket."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure7" class="figureid"&gt;Figure 7:&lt;/a&gt;
    Left: The pin mapping adapter schematic for using MT4264 chips in a socket designed for MK4108/4116 DRAM.
    Right: The replacement memory module on its adapter socket.
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Once we had replaced the one RAM module that was entirely broken, we started another memtest run, and no errors were reported at all.  This came as a surprise, since based on the results from previous memtests we assumed two modules had to be faulty.  We then reinserted the KERNAL ROM, and finally the CBM 3016 started up with its full memory size available:&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2022-02-09-commodore-cbm-basic-interpreter-16k.jpg"&gt;
    &lt;img src="https://s3lph.me/images/2022-02-09-commodore-cbm-basic-interpreter-16k.jpg"
         title="The CBM 3016 BASIC interpreter screen, now reporting 15K of free memory."
         alt="The CBM 3016 BASIC interpreter screen, now reporting 15K of free memory."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure8" class="figureid"&gt;Figure 8:&lt;/a&gt;
    The CBM 3016 BASIC interpreter screen, now reporting 15K of free memory.
  &lt;/figcaption&gt;
&lt;/figure&gt;</content><category term="hacks"/><category term="Hardware"/><category term="Repair"/><category term="CBM 3016"/></entry><entry><title>Layer 2 TFTP Relay for TFTP Recovery with IP Conflicts</title><link href="https://s3lph.me/layer-2-tftp-relay-with-ip-conflicts.html" rel="alternate"/><published>2022-01-09T00:00:00+01:00</published><updated>2022-01-09T00:00:00+01:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2022-01-09:/layer-2-tftp-relay-with-ip-conflicts.html</id><summary type="html">&lt;p&gt;Today I had to deal with an embedded device which refused to boot up
due to a misconfiguration.  Normally, this device, a Mitel DECT RFP 32
IP, obtains a DHCP lease on boot and fetches the firmware to run via
TFTP.  The firmware is downloaded at every reboot, since the …&lt;/p&gt;</summary><content type="html">&lt;p&gt;Today I had to deal with an embedded device which refused to boot up
due to a misconfiguration.  Normally, this device, a Mitel DECT RFP 32
IP, obtains a DHCP lease on boot and fetches the firmware to run via
TFTP.  The firmware is downloaded at every reboot, since the device can
not store it locally.&lt;/p&gt;
&lt;p&gt;It can however store some boot options in flash, such as static IP and
TFTP configuration.  And this is where the problem started: Someone
had accidentally configured &lt;code&gt;192.168.42.2&lt;/code&gt; for the device's own IP
AND the TFTP server IP.  Meaning the device would boot up, assign
itself a statically configured IPv4 address and then attempt to fetch
the boot image via TFTP from exactly the same IP.  Said TFTP traffic
of course never left the device.&lt;/p&gt;
&lt;h2 id="flash-chip-short-circuit"&gt;Flash Chip Short-Circuit&lt;/h2&gt;
&lt;p&gt;As a first measure, together with a friend, we took the device apart
and checked whether any of the chips on there was a flash storage
chip, and indeed we found one: An 8K I²C flash chip.  Hoping that this
is where this configuration was stored, we shorted the I²C bus' SDA to
GND in order to disable communication with said chip.&lt;/p&gt;
&lt;p&gt;Unfortunately this didn't do anything - apparently the boot
configuration is not stored on this chip.  And unfortunately we didn't
find another storage chip on the PCB.  So I decided to try another
approach.&lt;/p&gt;
&lt;p&gt;At least probing around the board we found a serial console, on which
the device wrote log output during the boot process, which did help a
lot.&lt;/p&gt;
&lt;h2 id="arp-spoofing"&gt;ARP Spoofing&lt;/h2&gt;
&lt;p&gt;I first attempted ARP spoofing to convince the device to send its TFTP
requests to the outside world:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="gp"&gt;$ &lt;/span&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;pacman&lt;span class="w"&gt; &lt;/span&gt;-S&lt;span class="w"&gt; &lt;/span&gt;dsniff
&lt;span class="gp"&gt;$ &lt;/span&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;arpspoof&lt;span class="w"&gt; &lt;/span&gt;-i&lt;span class="w"&gt; &lt;/span&gt;enp2s0&lt;span class="w"&gt; &lt;/span&gt;-c&lt;span class="w"&gt; &lt;/span&gt;both&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;192&lt;/span&gt;.168.42.2
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;And indeed, amidst all the unsolicited ARP replies TFTP requests
started popping up!  So, at least one direction was working now.  I
did have to assign the same &lt;code&gt;192.168.42.2&lt;/code&gt; to my notebook computer
running the TFTP server.  After doing so, the TFTP server started
responding.&lt;/p&gt;
&lt;p&gt;However, the TFTP response did not make it out onto the network, as
the destination IPv4 was assigned to the notebook itself.  I tried to
mess around with ebtables/arptables a bit, but didn't find anything
that worked as intended.&lt;/p&gt;
&lt;h2 id="layer-2-relay"&gt;Layer 2 Relay&lt;/h2&gt;
&lt;p&gt;So I decided to attempt to get it to work by having the TFTP server
listen on localhost only, and write a small piece of software which
would relay the network traffic between localhost and the LAN.&lt;/p&gt;
&lt;p&gt;I originally planned to simply open two raw sockets (i.e. sending and
receiving full Ethernet frames), one bound to the loopback interface,
and one bound to the LAN interface.  This piece software would then
simply relay the Ethernet frames between the two interfaces, but
replace MAC and IP addresses as needed, and recalculate the IPv4
header checksum along the way:&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2022-01-09-layer-2-relay.png"&gt;
    &lt;img src="https://s3lph.me/images/2022-01-09-layer-2-relay.png"
         title="Visualization of the first layer 2 relay approach. The frames are passed almost as-is, only the MAC addresses and IP addresses are replaced, and the IPv4 header checksum is recomputed."
         alt="Visualization of the first layer 2 relay approach. The frames are passed almost as-is, only the MAC addresses and IP addresses are replaced, and the IPv4 header checksum is recomputed."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure1" class="figureid"&gt;Figure 1:&lt;/a&gt;
    Visualization of the first layer 2 relay approach. The frames are passed almost as-is, only the MAC addresses and IP addresses are replaced, and the IPv4 header checksum is recomputed.
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;However, for some reason this did not work out quite as intended.  It
turned out that the frames sent out to the loopback interface also
ended up being caught by the relay software and be sent out to the
LAN, somehow disrupting TFTP traffic.  The workaround I ended up with
was to use a regular UDP datagram socket on the loopback interface
instead.  With this approach, frames coming in on the LAN interface
would be stripped of their Ethernet, IP and UDP headers and the
payload forwarded to the loopback UDP socket.  For responses from the
TFTP server, on the other hand, the relay had to construct these
headers and send a full Ethernet frame out to the LAN:&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2022-01-09-layer-2-udp-relay.png"&gt;
    &lt;img src="https://s3lph.me/images/2022-01-09-layer-2-udp-relay.png"
         title="Visualization of the layer 2 / UDP relay approach. This time, there is a UDP datagram socket on the loopback side, and protocol overhead is removed and added in the relay."
         alt="Visualization of the layer 2 / UDP relay approach. This time, there is a UDP datagram socket on the loopback side, and protocol overhead is removed and added in the relay."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure2" class="figureid"&gt;Figure 2:&lt;/a&gt;
    Visualization of the layer 2 / UDP relay approach. This time, there is a UDP datagram socket on the loopback side, and protocol overhead is removed and added in the relay.
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;And it worked! At  least at first: The first client  - server - client
roundtrip  went  through.   However,  when  the  client  attempted  to
acknowledge this  transaction, things  started to fall  apart.  Closer
inspection of the network traffic  revealed that the UDP payload sizes
didn't match.  The TFTP ACK was  much longer than it should have been,
and the excess bytes looked oddly familiar.&lt;/p&gt;
&lt;p&gt;As it turns out, the DECT RFP is reusing the same buffer for outgoing
TFTP messages over and over again, but when sending those messages out
to the network, they are not limited to the actual message length;
instead the full buffer is sent.  The length fields in the UDP and IP
headers were correctly set to only include the actual message
though. The remainder of the buffer was just noise at the end of the Ethernet frame.&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2022-01-09-tftp-buffer-reuse.png"&gt;
    &lt;img src="https://s3lph.me/images/2022-01-09-tftp-buffer-reuse.png"
         title="Visualization of the TFTP buffer reuse issue. Note how the IPv4 and UDP headers terminate at the end of the ACK message, but the Ethernet frame still contains the entire buffer."
         alt="Visualization of the TFTP buffer reuse issue. Note how the IPv4 and UDP headers terminate at the end of the ACK message, but the Ethernet frame still contains the entire buffer."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure3" class="figureid"&gt;Figure 3:&lt;/a&gt;
    Visualization of the TFTP buffer reuse issue. Note how the IPv4 and UDP headers terminate at the end of the ACK message, but the Ethernet frame still contains the entire buffer.
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;This caused problems in the relay software, since I was deducing the
IP and UDP length fields from the total length of the received
Ethernet frame.  Reading the actual payload size from the UDP header
instead of just assuming its size resolved the problem.&lt;/p&gt;
&lt;p&gt;And with that, it was finally working: The DECT RFP successfully
pulled its firmware image and booted.  From this point, we were able
to perform a factory reset, which switched it back to DHCP.&lt;/p&gt;
&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;It turns out you can make two hosts on the same network talk to each
other even when they have the same IP address.  You just need to take
a dive down to the Ethernet layer and take routing decisions away from
your Operating System.  The relay software I wrote for this purpose
can be found on &lt;a href="https://gitlab.com/-/snippets/2232487"&gt;Gitlab&lt;/a&gt;.  It still needs to be used in
conjunction with &lt;code&gt;arpspoof&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Though, in hindsight, it would probably have been possible to get the
TFTP responses out onto the network simply by messing with my notebook
computer's ARP table.  However, I didn't think of this at the time and
haven't had an opportunity to try since.  (Not gonna intentionally
brick that device again just for testing purposes.)&lt;/p&gt;</content><category term="hacks"/><category term="Boot"/><category term="TFTP"/><category term="Network"/><category term="Hardware"/></entry><entry><title>Chainload GRUB 2 from GRUB 2</title><link href="https://s3lph.me/chainload-grub2-from-grub2.html" rel="alternate"/><published>2021-11-27T00:00:00+01:00</published><updated>2021-11-27T00:00:00+01:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2021-11-27:/chainload-grub2-from-grub2.html</id><summary type="html">&lt;p&gt;I recently needed to set up a PC to dual boot Windows and Linux.
Legacy BIOS, no UEFI.  Unfortunately Windows likes to do Windows
things and override the first-stage GRUB bootloader in the MBR with
its own, and thereafter booting Windows only.&lt;/p&gt;
&lt;p&gt;As this PC needs to be used by …&lt;/p&gt;</summary><content type="html">&lt;p&gt;I recently needed to set up a PC to dual boot Windows and Linux.
Legacy BIOS, no UEFI.  Unfortunately Windows likes to do Windows
things and override the first-stage GRUB bootloader in the MBR with
its own, and thereafter booting Windows only.&lt;/p&gt;
&lt;p&gt;As this PC needs to be used by different persons with different
technological skillsets, and in order to make repairing the boot
process easier even if e.g. no Linux live boot medium were available,
I came up with the following solution which loads GRUB via PXE to
repair the "real" bootloader.&lt;/p&gt;
&lt;p&gt;This solution is quite easy for end users.  In the case of this
specific PC, PXE boot can be triggered by keeping &lt;kbd&gt;F8&lt;/kbd&gt;
pressed during boot.  The instructions to do so fit on a small post-it
note which I attached to the PC's screen.&lt;/p&gt;
&lt;h2 id="set-up-dhcp-and-tftp"&gt;Set Up DHCP and TFTP&lt;/h2&gt;
&lt;p&gt;You'll need:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A configurable DHCP server&lt;/li&gt;
&lt;li&gt;A TFTP server&lt;/li&gt;
&lt;li&gt;A device with GRUB 2 already installed&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;All of those can of course be on the same device.  Sometimes the first
two even have to be, since a lot of consumer-grade network cards can
only load data via TFTP from the same device that's running the TFTP
server.  Basically they ignore and don't request DHCP option 66
(server-name), and only handle DHCP option 67 (bootfile-name) and
attempt to load it via TFTP from the address that offered the DHCP
lease.&lt;/p&gt;
&lt;p&gt;First we need to set up the TFTP server. I'll use dnsmasq for both
TFTP and DHCP.  TFTP configuration in dnsmasq is as easy as the
following two lines in &lt;code&gt;/etc/dnsmasq.conf&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="na"&gt;enable-tftp&lt;/span&gt;
&lt;span class="na"&gt;tftp-root&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/srv/tftp&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;We then need to configure a static DHCP lease with additional DHCP
options.  Again in &lt;code&gt;dnsmasq.conf&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="c1"&gt;# &amp;quot;pool&amp;quot; is the pool name&lt;/span&gt;
&lt;span class="na"&gt;dhcp-range&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;pool,192.168.0.10,192.168.0.254,24h&lt;/span&gt;
&lt;span class="c1"&gt;# &amp;quot;target&amp;quot; is the target&amp;#39;s host name. &amp;quot;tftp-grub&amp;quot; is a &amp;quot;tag&amp;quot; used in the next two lines.&lt;/span&gt;
&lt;span class="na"&gt;dhcp-host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;01:23:45:67:89:0a,set:tftp-grub,192.168.0.10,target&lt;/span&gt;
&lt;span class="c1"&gt;# Set TFTP server and bootfile path&lt;/span&gt;
&lt;span class="na"&gt;dhcp-option&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;tag:tftp-grub,option:tftp-server,192.168.0.1&lt;/span&gt;
&lt;span class="na"&gt;dhcp-option&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;tag:tftp-grub,option:bootfile-name,/grub/i386-pc/core.0&amp;quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;h2 id="create-a-grub-2-netboot-environment"&gt;Create a GRUB 2 Netboot Environment&lt;/h2&gt;
&lt;p&gt;On the DHCP server (can also be done on a different host and then
copied over), run the following command:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;grub-mknetdir&lt;span class="w"&gt; &lt;/span&gt;--net-directory&lt;span class="w"&gt; &lt;/span&gt;/srv/tftp&lt;span class="w"&gt; &lt;/span&gt;--subdir&lt;span class="w"&gt; &lt;/span&gt;grub&lt;span class="w"&gt; &lt;/span&gt;-d&lt;span class="w"&gt; &lt;/span&gt;/usr/lib/grub/i386-pc/
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;The main difference between a system's &lt;code&gt;/boot/grub&lt;/code&gt; and this netboot
environment is the &lt;code&gt;grub/i386-pc/core.0&lt;/code&gt; file.  This is GRUB's
first-stage loader, the same thing that normally gets written to the
MBR, but here it's in a file instead, which can be loaded via TFTP.&lt;/p&gt;
&lt;p&gt;If you now tell your target machine to boot from PXE, you should get a
GRUB console.&lt;/p&gt;
&lt;h2 id="chainload-grub-2-from-grub-2"&gt;Chainload GRUB 2 from GRUB 2&lt;/h2&gt;
&lt;p&gt;I searched around a long time to figure out how this is done, and most
of the results i got said to "just load the second GRUB's config file
into the first GRUB instance".  This may work in most cases, but will
eventually break in this case, since the second GRUB's config file may
attempt to load additional modules from it's own prefix
(&lt;code&gt;/boot/grub/i386-pc&lt;/code&gt;), which will be incompatible with the PXE-loaded
GRUB if they're not the same version.&lt;/p&gt;
&lt;p&gt;Long story short, unlike GRUB 1, GRUB 2's second-stage loader (the
&lt;code&gt;core.img&lt;/code&gt; file) cannot be loaded on its own. Only the first-stage
loader (from MBR or &lt;code&gt;core.0&lt;/code&gt;) can be chainloaded.  And since we're
trying to cover the case where the first-stage loader has been
overwritten, we need to get it from somewhere else.&lt;/p&gt;
&lt;p&gt;In this case I decided to set up a small loopback image in the target
system's root partition which contained a copy of the system's GRUB
installation.&lt;/p&gt;
&lt;p&gt;To set up this loopback image, run the following commands on the
target system:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;dd&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/dev/zero&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;of&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/grubloop&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;bs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;100M&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;
parted&lt;span class="w"&gt; &lt;/span&gt;/grubloop&lt;span class="w"&gt; &lt;/span&gt;mklabel&lt;span class="w"&gt; &lt;/span&gt;msdos
&lt;span class="c1"&gt;# Offset from the start is needed so that there&amp;#39;s enough space for&lt;/span&gt;
&lt;span class="c1"&gt;# the first-stage loader.&lt;/span&gt;
parted&lt;span class="w"&gt; &lt;/span&gt;/grubloop&lt;span class="w"&gt; &lt;/span&gt;mkpart&lt;span class="w"&gt; &lt;/span&gt;primary&lt;span class="w"&gt; &lt;/span&gt;ext4&lt;span class="w"&gt; &lt;/span&gt;1048576B&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;%
parted&lt;span class="w"&gt; &lt;/span&gt;/grubloop&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;boot&lt;span class="w"&gt; &lt;/span&gt;on
losetup&lt;span class="w"&gt; &lt;/span&gt;-fo&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;1048576&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;/grubloop
&lt;span class="c1"&gt;# Loop device number may be different&lt;/span&gt;
mkfs.ext4&lt;span class="w"&gt; &lt;/span&gt;/dev/loop0
losetup&lt;span class="w"&gt; &lt;/span&gt;-d&lt;span class="w"&gt; &lt;/span&gt;/dev/loop0
mkdir&lt;span class="w"&gt; &lt;/span&gt;/altboot
mount&lt;span class="w"&gt; &lt;/span&gt;-o&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;offset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;1048576&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;/grubloop&lt;span class="w"&gt; &lt;/span&gt;/altboot
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Also enter this into your &lt;code&gt;/etc/fstab&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;grubloop&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;altboot&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nv"&gt;ext4&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="k"&gt;loop&lt;/span&gt;,&lt;span class="nv"&gt;offset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1048576&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Finally install GRUB to &lt;code&gt;/grubloop&lt;/code&gt; resp. &lt;code&gt;/altboot&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;grub-install --boot-directory=/altboot --target=i386-pc --force /grubloop
grub-mkconfig -o /altboot/grub/grub.cfg
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;This image, which now contains the same boot config as your
&lt;code&gt;/boot/grub&lt;/code&gt;, can now be booted like you'd boot any hard disk.  To
chainload this GRUB image from the PXE grub, create the file
&lt;code&gt;/srv/tftp/grub/grub.cfg&lt;/code&gt; on your TFTP server and add the following:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;insmod&lt;span class="w"&gt; &lt;/span&gt;biosdisk
insmod&lt;span class="w"&gt; &lt;/span&gt;part_msdos
insmod&lt;span class="w"&gt; &lt;/span&gt;ext2
insmod&lt;span class="w"&gt; &lt;/span&gt;chain

&lt;span class="c1"&gt;# Adapt to your real Linux root partition&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;root&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;h0,msdos4&lt;span class="o"&gt;)&lt;/span&gt;
chainloader&lt;span class="w"&gt; &lt;/span&gt;/grubloop&lt;span class="w"&gt; &lt;/span&gt;+1
boot
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;I'm intentionally hardcoding the root partition here, instead of using
GRUB's &lt;code&gt;search&lt;/code&gt; module, since I want the PXE boot to be fast and
simple.  The &lt;code&gt;search&lt;/code&gt; module, on the other hand, loads a LOT of other
modules via TFTP, which may become a performance issue.&lt;/p&gt;
&lt;p&gt;When you now boot your target device via PXE, it will load the first
GRUB instance from TFTP, which will then chainload the second GRUB
instance from the loopback image, which will then boot into Linux.&lt;/p&gt;
&lt;h2 id="automatically-re-install-grub"&gt;Automatically Re-Install GRUB&lt;/h2&gt;
&lt;p&gt;I'm doing all of the above not only to boot via PXE, but also to
automatically re-install GRUB to the disk's MBR.  I achieved this with
the following systemd unit, which is executed on every boot of the
Linux system:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="k"&gt;[Unit]&lt;/span&gt;
&lt;span class="na"&gt;Description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Reinstall GRUB 2 to MBR and loopback altboot&lt;/span&gt;

&lt;span class="k"&gt;[Service]&lt;/span&gt;
&lt;span class="na"&gt;Type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;oneshot&lt;/span&gt;
&lt;span class="na"&gt;ExecStart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/sbin/grub-install --boot-directory=/altboot --target=i386-pc --force /grubloop&lt;/span&gt;
&lt;span class="na"&gt;ExecStart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/sbin/grub-install --boot-directory=/boot --target=i386-pc /dev/disk/by-id/&amp;lt;DISK_ID&amp;gt;&lt;/span&gt;
&lt;span class="na"&gt;ExecStart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/sbin/grub-mkconfig -o /altboot/grub/grub.cfg&lt;/span&gt;
&lt;span class="na"&gt;ExecStart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/sbin/grub-mkconfig -o /boot/grub/grub.cfg&lt;/span&gt;

&lt;span class="k"&gt;[Install]&lt;/span&gt;
&lt;span class="na"&gt;WantedBy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;multi-user.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Maybe a bit overkill, but it gets the job done and is easy to use for
end users.&lt;/p&gt;</content><category term="hacks"/><category term="GRUB"/><category term="Boot"/><category term="PXE"/></entry><entry><title>DNSSEC Automation with Knot - Automated KSK Rollover</title><link href="https://s3lph.me/automated-dnssec-ksk-rollover.html" rel="alternate"/><published>2021-11-16T00:00:00+01:00</published><updated>2021-11-16T00:00:00+01:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2021-11-16:/automated-dnssec-ksk-rollover.html</id><summary type="html">&lt;p&gt;&lt;strong&gt;UPDATE&lt;/strong&gt;: Due to how KSK algorithm rollovers work,
&lt;a href="https://gitlab.nic.cz/knot/knot-dns/-/issues/804"&gt;&lt;code&gt;cds-cdnskey-publish: double-ds&lt;/code&gt; should not be used&lt;/a&gt;.  The
configuration example below has been updated to use
&lt;code&gt;cds-cdnskey-publish: always&lt;/code&gt; instead.&lt;/p&gt;
&lt;p&gt;In the previous post I've shown how to bootstrap DNSSEC in a fully
automated fashion using the &lt;a href="https://www.knot-dns.cz/"&gt;Knot DNS server&lt;/a&gt; and a
&lt;a href="https://datatracker.ietf.org/doc/html/rfc8078"&gt;CDS&lt;/a&gt;-enabled …&lt;/p&gt;</summary><content type="html">&lt;p&gt;&lt;strong&gt;UPDATE&lt;/strong&gt;: Due to how KSK algorithm rollovers work,
&lt;a href="https://gitlab.nic.cz/knot/knot-dns/-/issues/804"&gt;&lt;code&gt;cds-cdnskey-publish: double-ds&lt;/code&gt; should not be used&lt;/a&gt;.  The
configuration example below has been updated to use
&lt;code&gt;cds-cdnskey-publish: always&lt;/code&gt; instead.&lt;/p&gt;
&lt;p&gt;In the previous post I've shown how to bootstrap DNSSEC in a fully
automated fashion using the &lt;a href="https://www.knot-dns.cz/"&gt;Knot DNS server&lt;/a&gt; and a
&lt;a href="https://datatracker.ietf.org/doc/html/rfc8078"&gt;CDS&lt;/a&gt;-enabled domain registry.  (Again, when I'm saying CDS,
this is meant to include CDNSKEY as well.)&lt;/p&gt;
&lt;p&gt;Now I'm going to take this a step further and show how to have all the
tasks required for a &lt;abbr title="Key Signing Keys"&gt;KSK&lt;/abbr&gt; rollover
performed automatically by Knot.&lt;/p&gt;
&lt;h2 id="the-ksk-rollover-process"&gt;The KSK Rollover Process&lt;/h2&gt;
&lt;p&gt;We start out with an already established trust relationship between a
zone and its parent.  Same as before, I'll use my &lt;code&gt;s3lph.ch.&lt;/code&gt; domain
as an example.&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2021-11-15-automated-dnssec-ksk-rollover-01.svg"&gt;
    &lt;img src="https://s3lph.me/images/2021-11-15-automated-dnssec-ksk-rollover-01.png"
         title="Visualization of the DNSSEC chain of trust before beginning the KSK rollover process."
         alt="Visualization of the DNSSEC chain of trust before beginning the KSK rollover process."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure1" class="figureid"&gt;Figure 1:&lt;/a&gt;
    Visualization of the DNSSEC chain of trust before beginning the KSK rollover process,
    as rendered by the online tool &lt;a href="https://dnsviz.net/d/s3lph.ch/dnssec/"&gt;DNSViz&lt;/a&gt;.
    Again, I've added the CDS nodes afterwards.  The trust relationship between the
    zone and its parent is established, and there is one active KSK and one active ZSK.
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;When the current KSK is about to expire, Knot will automatically
generate a new KSK and publish it in the zone.  It is signed by the
current KSK:&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2021-11-15-automated-dnssec-ksk-rollover-02.svg"&gt;
    &lt;img src="https://s3lph.me/images/2021-11-15-automated-dnssec-ksk-rollover-02.png"
         title="Visualization of the DNSSEC chain of trust after publishing the new key."
         alt="Visualization of the DNSSEC chain of trust after publishing the new key."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure2" class="figureid"&gt;Figure 2:&lt;/a&gt;
    Visualization of the DNSSEC chain of trust after publishing the new key.
    The new KSK (&lt;span color="red"&gt;42001&lt;/span&gt;) is signed by the current key (46451).
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Once Knot can assume that all other nameservers in the zone have
replicated the new DNSKEY set (after the zone TTL has expired), a
corresponding CDS record is published.  The DNSKEY and CDS records are
signed by both the old and the new key.&lt;/p&gt;
&lt;p&gt;The exact behavior regarding the creation of CDS records depends on
the config option &lt;code&gt;cds-cdnskey-publish&lt;/code&gt;: If you chose &lt;code&gt;double-ds&lt;/code&gt;
(which I did here, &lt;strong&gt;please check the update at the top of the
page&lt;/strong&gt;), then records for both the old and the new KSK will be
published simultaneously during the rollover process.  If you chose
&lt;code&gt;rollover&lt;/code&gt; or &lt;code&gt;always&lt;/code&gt;, only a CDS record for the new key is
published.&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2021-11-15-automated-dnssec-ksk-rollover-03.svg"&gt;
    &lt;img src="https://s3lph.me/images/2021-11-15-automated-dnssec-ksk-rollover-03.png"
         title="Visualization of the DNSSEC chain of trust after publishing the new key."
         alt="Visualization of the DNSSEC chain of trust after publishing the new key."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure3" class="figureid"&gt;Figure 3:&lt;/a&gt;
    Visualization of the DNSSEC chain of trust after publishing the new CDS record.
    The CDS records and the ZSK are now signed by both keys.
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Knot will wait for the parent registry to publish the new
&lt;abbr title="Delegation signer"&gt;DS&lt;/abbr&gt; record.  Now we have reached a
"symmetry" in the rollover process where both keys are equally trusted
and used for ZSK signing:&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2021-11-15-automated-dnssec-ksk-rollover-04.svg"&gt;
    &lt;img src="https://s3lph.me/images/2021-11-15-automated-dnssec-ksk-rollover-04.png"
         title="Visualization of the DNSSEC chain of trust after the new DS record has been published."
         alt="Visualization of the DNSSEC chain of trust after the new DS has been published."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure4" class="figureid"&gt;Figure 4:&lt;/a&gt;
    Visualization of the DNSSEC chain of trust after the new DS record has been published.
    The ZSK is still signed by both KSKs, both of which are now trusted.
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Knot will periodically check for existence of the DS record.  Once it
has been found, the CDS records are updated to only refer to the new
key:&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2021-11-15-automated-dnssec-ksk-rollover-05.svg"&gt;
    &lt;img src="https://s3lph.me/images/2021-11-15-automated-dnssec-ksk-rollover-05.png"
         title="Visualization of the DNSSEC chain of trust after Knot has removed the old CDS record."
         alt="Visualization of the DNSSEC chain of trust after Knot has removed the old CDS record."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure5" class="figureid"&gt;Figure 5:&lt;/a&gt;
    Visualization of the DNSSEC chain of trust after Knot has removed the old CDS record.
    Both keys are still being used for ZSK signing, however, only the new CDS record is published.
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;A short while after (again after having waited until all other
nameservers have had time to replicate the changes) Knot will retire
the old key, meaning it's no longer being used for ZSK signing.  The
zone gets resigned using only the new KSK:&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2021-11-16-automated-dnssec-ksk-rollover-06.svg"&gt;
    &lt;img src="https://s3lph.me/images/2021-11-16-automated-dnssec-ksk-rollover-06.png"
         title="Visualization of the DNSSEC chain of trust after the old key has been retired."
         alt="Visualization of the DNSSEC chain of trust after the old key has been retired."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure6" class="figureid"&gt;Figure 6:&lt;/a&gt;
    Visualization of the DNSSEC chain of trust after the old key has been retired.
    Now only the new key (42001) is used for ZSK signing.
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Sometime afterwards the parent registry will scrape for CDS records
again and discover the new CDS RRset, which now only contains the new
CDS record.  This will cause the old DS record to be removed from the
parent zone, only the new DS record remains:&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2021-11-16-automated-dnssec-ksk-rollover-07.svg"&gt;
    &lt;img src="https://s3lph.me/images/2021-11-16-automated-dnssec-ksk-rollover-07.png"
         title="Visualization of the DNSSEC chain of trust after trust for the old key has been removed."
         alt="Visualization of the DNSSEC chain of trust after trust for the old key has been removed."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure7" class="figureid"&gt;Figure 7:&lt;/a&gt;
    Visualization of the DNSSEC chain of trust after trust for the old key has been removed.
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Finally, some time afterwards, Knot will remove the old key from the
zone, and only the new chain of trust remains:&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2021-11-16-automated-dnssec-ksk-rollover-08.svg"&gt;
    &lt;img src="https://s3lph.me/images/2021-11-16-automated-dnssec-ksk-rollover-08.png"
         title="Visualization of the DNSSEC chain of trust after the old key has been removed."
         alt="Visualization of the DNSSEC chain of trust after the old key has been removed."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure8" class="figureid"&gt;Figure 8:&lt;/a&gt;
    Visualization of the DNSSEC chain of trust after the old key has been removed.
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h2 id="set-up-knot-for-fully-automated-ksk-rollovers"&gt;Set Up Knot for Fully Automated KSK Rollovers&lt;/h2&gt;
&lt;p&gt;Let's have a look at &lt;code&gt;/etc/knot/knot.conf&lt;/code&gt; again, once more reduced to
the relevant bits:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nt"&gt;zone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;s3lph.ch.&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;dnssec-signing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;on&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;dnssec-policy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;dnssec-s3lph.ch.&lt;/span&gt;

&lt;span class="nt"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;

&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;dnssec-s3lph.ch.&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;# These options are explained in the previous blogpost. &lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;algorithm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;ecdsap384sha384&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;nsec3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;on&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;cds-cdnskey-publish&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;always&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;# ZSKs are valid for 30 days by default, KSKs are valid indefinitely&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;# This instructs knot to initiate a KSK rollover every 180 days.&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;ksk-lifetime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;180d&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;# This will add an additional backoff time to account for cache ttl.&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;# Choose this appropriately to your zone&amp;#39;s TTL.  Defaults to 1h.&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;propagation-delay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;1h&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;# This tells Knot how to check for new DS records after the key has&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;# been published.&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;ksk-submission&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;submission-s3lph.ch.&lt;/span&gt;

&lt;span class="nt"&gt;submission&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;

&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;submission-s3lph.ch.&lt;/span&gt;
&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c1"&gt;# How often to check for new DS records.&lt;/span&gt;
&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="nt"&gt;check-interval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;1h&lt;/span&gt;
&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c1"&gt;# Nameservers to check for new DS records, must be configure in the&lt;/span&gt;
&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c1"&gt;# &amp;quot;remote&amp;quot; section below. Using an external, DNSSEC-validating&lt;/span&gt;
&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c1"&gt;# resolver makes sure the old DS records aren&amp;#39;t cached anymore.&lt;/span&gt;
&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="nt"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;remote-1.1.1.1&lt;/span&gt;

&lt;span class="nt"&gt;remote&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;

&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;remote-1.1.1.1&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;2606:4700:4700::1111&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;1.1.1.1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;This is all that's required to get fully automated KSK rollover
running. A bit more config than for the last blogpost, but still
manageable.  Again the config options are documented in detail in the
&lt;a href="https://www.knot-dns.cz/docs/latest/html/reference.html"&gt;Knot Configuration Reference&lt;/a&gt;. &lt;/p&gt;
&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;I don't think KSK rollovers have ever been this easy; this is
basically a "fire and forget" operation and everything's taken care
off afterwards.&lt;/p&gt;
&lt;p&gt;You can even make use of these features if your domain registry is not
CDS-enabled.  In this case there's one manual step involved though: You
have to manually submit the new DS record to your registry, usually
through your registrar's config interface.  Everything else, including
the auto-detection of the new DS record in the parent zone will still
work automatically.  You'll of course have to rely on your monitoring
to know when a KSK rollover is due.&lt;/p&gt;
&lt;p&gt;You're monitoring your DNS, right? ... Right?&lt;/p&gt;</content><category term="hacks"/><category term="DNS"/><category term="DNSSEC"/><category term="Automation"/><category term="Knot"/></entry><entry><title>DNSSEC Automation with Knot - Automated Bootstrapping</title><link href="https://s3lph.me/automated-dnssec-bootstrapping.html" rel="alternate"/><published>2021-11-14T00:00:00+01:00</published><updated>2021-11-14T00:00:00+01:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2021-11-14:/automated-dnssec-bootstrapping.html</id><summary type="html">&lt;p&gt;&lt;strong&gt;UPDATE&lt;/strong&gt;: Due to how KSK algorithm rollovers work,
&lt;a href="https://gitlab.nic.cz/knot/knot-dns/-/issues/804"&gt;&lt;code&gt;cds-cdnskey-publish: double-ds&lt;/code&gt; should not be used&lt;/a&gt;.  The
configuration example below has been updated to use
&lt;code&gt;cds-cdnskey-publish: always&lt;/code&gt; instead.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://www.knot-dns.cz/"&gt;Knot DNS&lt;/a&gt; is an authoritative DNS name server developed by
CZ.NIC.  One of the features that sets it apart from BIND and other …&lt;/p&gt;</summary><content type="html">&lt;p&gt;&lt;strong&gt;UPDATE&lt;/strong&gt;: Due to how KSK algorithm rollovers work,
&lt;a href="https://gitlab.nic.cz/knot/knot-dns/-/issues/804"&gt;&lt;code&gt;cds-cdnskey-publish: double-ds&lt;/code&gt; should not be used&lt;/a&gt;.  The
configuration example below has been updated to use
&lt;code&gt;cds-cdnskey-publish: always&lt;/code&gt; instead.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://www.knot-dns.cz/"&gt;Knot DNS&lt;/a&gt; is an authoritative DNS name server developed by
CZ.NIC.  One of the features that sets it apart from BIND and other
nameserver software is its support for fully automated DNSSEC key
management.  In this blogpost I'll show how to achieve fully automated
DNSSEC bootstrapping, assuming that the parent zone implements trust
management via CDS/CDNSKEY records.&lt;/p&gt;
&lt;h2 id="rfc-8078-cds-and-cdnskey"&gt;RFC 8078: CDS and CDNSKEY&lt;/h2&gt;
&lt;p&gt;Usually, in order to bootstrap a DNSSEC trust relationship, a series
of steps is necessary:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;A &lt;abbr title="Key Signing Key"&gt;KSK&lt;/abbr&gt; and
   &lt;abbr title="Zone Signing Key"&gt;ZSK&lt;/abbr&gt; or
   &lt;abbr title="Combined Signing Key"&gt;CSK&lt;/abbr&gt; must be created for
   the child zone (e.g. &lt;code&gt;s3lph.ch.&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;The public keys must be published in the child zone as DNSKEY
   records.&lt;/li&gt;
&lt;li&gt;The zone's records must be signed with the ZSK, with the exception of
    the DNSKEY records, which must be signed by the KSK.&lt;/li&gt;
&lt;li&gt;A &lt;abbr title="Delegation Signer"&gt;DS&lt;/abbr&gt; record matching the
   KSK must be created.&lt;/li&gt;
&lt;li&gt;The DS record must be
   published in the &lt;strong&gt;parent zone&lt;/strong&gt; (e.g. &lt;code&gt;ch.&lt;/code&gt;).&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Most of these steps can be automated and contained within a single
piece of software such as Knot, but the last step usually requires a
human to sign into their domain registrar's web management interface
and configure the DS record manually.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc8078"&gt;RFC 8087&lt;/a&gt; describes a mechanism to automate this last
step. In order to achieve this, two new DNS RR types were introduced:
CDS and CDNSKEY.  They serve as a way for the child zone to tell the
parent zone "I want the parent zone to serve DS records for these
KSKs".  Both types serve the same purpose, the only difference is that
the CDNSKEY records are copies of the DNSKEY records for which a DS
record should be created in the parent zone, whereas the CDS records
are already-prepared DS records for the parent zone to copy. This can
not only be used for bootstrapping DNSSEC upon introduction, but also
for maintaining the chain of trust when rolling over to a new KSK.&lt;/p&gt;
&lt;p&gt;The procedure to bootstrap or update a trust relationship using
CDS/CDNSKEY records boils down to:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The CDNSKEY and/or CDS records are published in the child zone.&lt;/li&gt;
&lt;li&gt;The parent zone scans all its child zones for CDNSKEY/CDS changes
   on a regular basis (e.g. once a day).  Usually this is done by the
   registry operating the zone.&lt;/li&gt;
&lt;li&gt;DS records are computed from the CDNSKEY/CDS records and inserted
   into the parent zone.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Usually this last step is bound to a set of policies defined by the
registry.  For example, the &lt;a href="https://www.nic.ch/security/cds/"&gt;CDS policies of SWITCH&lt;/a&gt;, the
registry for the &lt;code&gt;ch.&lt;/code&gt; and &lt;code&gt;li.&lt;/code&gt; TLDs, require among other things:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The set of CDS records must be valid and consistent across all of
  the zone's nameservers&lt;/li&gt;
&lt;li&gt;The new DS records must not break the chain of trust&lt;/li&gt;
&lt;li&gt;the CDS records are signed with an already trusted KSK&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;: From hereon, every mention of &lt;em&gt;CDS&lt;/em&gt; will imply &lt;em&gt;CDS and/or
CDNSKEY&lt;/em&gt; unless otherwise noted.&lt;/p&gt;
&lt;h2 id="dnssec-bootstrap-example"&gt;DNSSEC Bootstrap Example&lt;/h2&gt;
&lt;p&gt;Let's look at an example and have DNSSEC automatically bootstrapped
for the &lt;code&gt;s3lph.ch.&lt;/code&gt; zone.  At first, immediately after setting up Knot
to enable DNSSEC, keys are generated, the zone is signed and the keys
and CDS records are published in the zone:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="c1"&gt;; KSK and ZSK&lt;/span&gt;
&lt;span class="nf"&gt;s3lph.ch.&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;IN&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="no"&gt;DNSKEY&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="mi"&gt;257&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;zjmE...AnD5&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;; KSK ; key id = 46451&lt;/span&gt;
&lt;span class="nf"&gt;s3lph.ch.&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;IN&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="no"&gt;DNSKEY&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;fSZj...d&lt;/span&gt;&lt;span class="err"&gt;+&lt;/span&gt;&lt;span class="no"&gt;gU&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;; ZSK ; key id = 19881&lt;/span&gt;
&lt;span class="c1"&gt;; ZSK signature, signed with KSK&lt;/span&gt;
&lt;span class="nf"&gt;s3lph.ch.&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;IN&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="no"&gt;RRSIG&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="no"&gt;DNSKEY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;46451&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;...&lt;/span&gt;
&lt;span class="c1"&gt;; CDS referencing the KSK&lt;/span&gt;
&lt;span class="nf"&gt;s3lph.ch.&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;IN&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="no"&gt;CDS&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="mi"&gt;46451&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;C129...DDC0&lt;/span&gt;
&lt;span class="c1"&gt;; CDS signature, signed with KSK&lt;/span&gt;
&lt;span class="nf"&gt;s3lph.ch.&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;IN&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="no"&gt;RRSIG&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="no"&gt;CDS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;46451&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;...&lt;/span&gt;
&lt;span class="c1"&gt;; AAAA record, signed with ZSK&lt;/span&gt;
&lt;span class="nf"&gt;s3lph.ch.&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;IN&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="no"&gt;AAAA&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="no"&gt;a01&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="no"&gt;f8&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="no"&gt;c1c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="no"&gt;ae7&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="nf"&gt;s3lph.ch.&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;IN&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="no"&gt;RRSIG&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="no"&gt;AAAA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;19881&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;However, the &lt;code&gt;ch.&lt;/code&gt; zone does not yet know about DNSSEC in the &lt;code&gt;s3lph.ch.&lt;/code&gt; zone:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nf"&gt;s3lph.ch.&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="no"&gt;IN&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="no"&gt;NS&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="no"&gt;ns1.s3lph.me.&lt;/span&gt;
&lt;span class="nf"&gt;s3lph.ch.&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="no"&gt;IN&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="no"&gt;NS&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="no"&gt;ns2.s3lph.me.&lt;/span&gt;
&lt;span class="nf"&gt;S8BEAUIIVUO5RD59BCLAL4MK7MDKBIOC.ch.&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;900&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;IN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;NSEC&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;NS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;DS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;RRSIG&lt;/span&gt;
&lt;span class="nf"&gt;S8BEAUIIVUO5RD59BCLAL4MK7MDKBIOC.ch.&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;900&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;IN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;RRSIG&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;NSEC3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;You can also see the chain of trust visualized in &lt;a
href="#figure1"&gt;Figure 1&lt;/a&gt;.  The foundation for this image has been
created using the the online tool &lt;a href="https://dnsviz.net/d/s3lph.ch/dnssec/"&gt;DNSViz&lt;/a&gt; resp. it's
eponymous &lt;a href="https://github.com/dnsviz/dnsviz"&gt;backend tool&lt;/a&gt;.  I've added the CDS node manually
afterwards, since DNSViz does not (&lt;a href="https://github.com/dnsviz/dnsviz/issues/49"&gt;yet&lt;/a&gt;) support CDS.&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2021-11-13-automated-dnssec-bootstrapping-01.svg"&gt;
    &lt;img src="https://s3lph.me/images/2021-11-13-automated-dnssec-bootstrapping-01.png"
         title="Visualization of the DNSSEC chain of trust before CDS bootstrapping."
         alt="Visualization of the DNSSEC chain of trust before CDS bootstrapping."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure1" class="figureid"&gt;Figure 1:&lt;/a&gt;
    Visualization of the DNSSEC chain of trust before CDS bootstrapping.
    Note the black arrow between &lt;code&gt;ch.&lt;/code&gt; and &lt;code&gt;s3lph.ch.&lt;/code&gt;
    indicating insecure delegation, as well as the NSEC3 record indicating
    the absence of a DS record.
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;One day later, we can see that SWITCH has successfully picked up the
CDS record:&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2021-11-11-switch-cds-pre-bootstrap.png"&gt;
    &lt;img src="https://s3lph.me/images/2021-11-11-switch-cds-pre-bootstrap.png"
         title="SWITCH CDS Status Check tool reporting &amp;quot;pending&amp;quot; status after the first successful validation."
         alt="SWITCH CDS Status Check tool reporting &amp;quot;pending&amp;quot; status after the first successful validation."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure2" class="figureid"&gt;Figure 2:&lt;/a&gt;
    &lt;a href="https://nic.ch/security/cds/"&gt;SWITCH's CDS Status Check tool&lt;/a&gt; reporting "pending" status after the first successful validation.
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;However, SWITCH requires the CDS RRset to remain unchanged for 3 days
when bootstrapping DNSSEC on a zone, so they haven't published the
matching DS record yet.&lt;/p&gt;
&lt;p&gt;Let's fast-forward to 3 days later: There now is a DS record in the &lt;code&gt;ch.&lt;/code&gt; zone:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nf"&gt;s3lph.ch.&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="no"&gt;IN&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="no"&gt;NS&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="no"&gt;ns1.s3lph.me.&lt;/span&gt;
&lt;span class="nf"&gt;s3lph.ch.&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="no"&gt;IN&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="no"&gt;NS&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="no"&gt;ns2.s3lph.me.&lt;/span&gt;
&lt;span class="nf"&gt;s3lph.ch.&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="no"&gt;IN&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="no"&gt;DS&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="mi"&gt;46451&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;C129...DDC0&lt;/span&gt;
&lt;span class="nf"&gt;s3lph.ch.&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="no"&gt;IN&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="no"&gt;RRSIG&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="no"&gt;DS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2021-11-14-automated-dnssec-bootstrapping-02.svg"&gt;
    &lt;img src="https://s3lph.me/images/2021-11-14-automated-dnssec-bootstrapping-02.png"
         title="Visualization of the DNSSEC chain of trust after CDS bootstrapping."
         alt="Visualization of the DNSSEC chain of trust after CDS bootstrapping."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure3" class="figureid"&gt;Figure 3:&lt;/a&gt;
    Visualization of the DNSSEC chain of trust after CDS bootstrapping, as rendered
    by DNSViz.  Again, the CDS record was added afterwards.  Note the arrow between
    &lt;code&gt;ch.&lt;/code&gt; and &lt;code&gt;s3lph.ch.&lt;/code&gt; has turned blue, indicating secure
    delegation, as well as the new DS record in the &lt;code&gt;ch.&lt;/code&gt; zone.
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;And there we have it: The DNSSEC trust relationship between &lt;code&gt;ch.&lt;/code&gt; and
&lt;code&gt;s3lph.ch.&lt;/code&gt; has been established without any manual interaction.&lt;/p&gt;
&lt;p&gt;Now let's look into how to set up Knot for this.&lt;/p&gt;
&lt;h2 id="set-up-knot-for-fully-automated-dnssec-key-management"&gt;Set Up Knot for Fully Automated DNSSEC Key Management&lt;/h2&gt;
&lt;p&gt;Let's have a look at my &lt;code&gt;/etc/knot/knot.conf&lt;/code&gt;.  I've boiled it down to
the relevant pieces:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nt"&gt;zone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;s3lph.ch.&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;# This option tells Knot to enable automatic zone signing ...&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;dnssec-signing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;on&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;# ... and this tells Knot HOW to sign the zone.&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;dnssec-policy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;dnssec-s3lph.ch.&lt;/span&gt;

&lt;span class="nt"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="c1"&gt;# This is the Knot DNSSEC signing policy for s3lph.ch.&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;dnssec-s3lph.ch.&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;# SWITCH doesn&amp;#39;t yet support ed25519/ed448, so let&amp;#39;s use some other&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;# recommended algorithm, ECDSA using the P-384 curve, plus SHA384.&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;algorithm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;ecdsap384sha384&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;# Automatically create NSEC3PARAMs as well, and respond with NSEC3&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;# instead of regular NSEC.&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;nsec3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;on&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;# This is the magic bit to turn on CDS and CDNSKEY record generation.&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;# &amp;quot;always&amp;quot; means that a CDS record is always published, not only during KSK rollovers.&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;# Before, I had used &amp;quot;double-ds&amp;quot; here, which publishes a CDS RRset for both the&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;# old and the new key during rollover, which unfortunately breaks when rolling over to&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;# a different KSK algorithm (see update at the top of this page).&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;cds-cdnskey-publish&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;always&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;And that's already it; those few lines are enough to have Knot
automatically sign zones and generate CDS and CDNSKEY records on the
fly.&lt;/p&gt;
&lt;p&gt;There are other options available for the &lt;code&gt;cds-cdnskey-publish&lt;/code&gt; option,
you can look up the list in the &lt;a href="https://www.knot-dns.cz/docs/latest/html/reference.html#policy-cds-cdnskey-publish"&gt;Knot Configuration Reference&lt;/a&gt;. &lt;/p&gt;
&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;If your registry supports CDS or CDNSKEY, this is a really simple
method to bootstrap DNSSEC without any manual intervention.  Even
more, this still works if the registrar through which you bought your
domain doesn't offer a way to configure DS records.&lt;/p&gt;
&lt;p&gt;This procedure can also be used to make KSK rollovers fully automatic,
which I'll be covering in the next blogpost.&lt;/p&gt;</content><category term="hacks"/><category term="DNS"/><category term="DNSSEC"/><category term="Automation"/><category term="Knot"/></entry><entry><title>Mein Einbürgerungverfahren in Basel - Teil 2: Vorbereitung und Gesuchsstellung</title><link href="https://s3lph.me/einbuergerungsverfahren-in-basel-teil-2-vorbereitung-gesuchsstellung-de.html" rel="alternate"/><published>2021-09-20T23:16:00+02:00</published><updated>2021-09-20T23:16:00+02:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2021-09-20:/einbuergerungsverfahren-in-basel-teil-2-vorbereitung-gesuchsstellung-de.html</id><summary type="html">&lt;p&gt;&lt;strong&gt;Achtung&lt;/strong&gt;: Die Informationen in diesem Artikel beziehen sich auf das
Verfahren im Kanton Basel-Stadt, die Verfahren in anderen Kantonen
weichen z.T. stark ab.&lt;/p&gt;
&lt;p&gt;Normalerweise muss man sich vor der Einreichung des
Einbürgerungsgesuchs vom Migrationsamt beraten lassen.
Pandemiebedingt ist die Beratung momentan aber nur telefonisch oder
via E-Mail möglich.&lt;/p&gt;
&lt;h2 id="beratung-via-e-mail"&gt;Beratung …&lt;/h2&gt;</summary><content type="html">&lt;p&gt;&lt;strong&gt;Achtung&lt;/strong&gt;: Die Informationen in diesem Artikel beziehen sich auf das
Verfahren im Kanton Basel-Stadt, die Verfahren in anderen Kantonen
weichen z.T. stark ab.&lt;/p&gt;
&lt;p&gt;Normalerweise muss man sich vor der Einreichung des
Einbürgerungsgesuchs vom Migrationsamt beraten lassen.
Pandemiebedingt ist die Beratung momentan aber nur telefonisch oder
via E-Mail möglich.&lt;/p&gt;
&lt;h2 id="beratung-via-e-mail"&gt;Beratung via E-Mail&lt;/h2&gt;
&lt;p&gt;Einen Grossteil der Informationen zur Einbürgerung lässt sich online
finden.  Eines der wichtigsten Dokumente - die Checkliste, welche
Dokumente überhaupt einzureichen sind - wird aber nur bei der Beratung
ausgehändigt.&lt;/p&gt;
&lt;p&gt;In meinem Fall hatte sich die Beratung auf einen kurzen
E-Mail-Austausch beschränkt: Ich habe meine Situation geschildert und
ein paar Fragen zu den Online-Informationen gestellt, und hatte schon
am nächsten Tag eine Antwort in der Inbox, mit der Checkliste im
Anhang.  Soweit, so gut.  Allerdings fände ich es durchaus
begrüssenswert, wenn die Checkliste online verfügbar wäre, allein nur
schon deswegen, damit man den Aufwand für das Zusammentragen der
Dokumente im Voraus abschätzen kann.&lt;/p&gt;
&lt;p&gt;Die Checkliste ist sehr umfangreich.  Neben der Checkliste selbst sind
unter anderem folgende Dokumente einzureichen:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Das Gesuchsformular, in dem man Angaben zur Person, Wohnsitzfristen
  etc. macht&lt;/li&gt;
&lt;li&gt;Kopien von Ausweisen und Aufenthaltstiteln&lt;/li&gt;
&lt;li&gt;Wohnsitzbestätigungen, Betreibungsregisterauszüge und Steuerausweise
  von ehemaligen Wohnkantonen (in meinem Fall der Kanton Aargau)&lt;/li&gt;
&lt;li&gt;Ein Registerauszug vom Zivilstandsamt, mehr dazu weiter unten&lt;/li&gt;
&lt;li&gt;Nachweise über Berufstätigkeit, Ausbildung o.Ä.&lt;/li&gt;
&lt;li&gt;Ein Sprachnachweis, wobei in meinem Fall die Selbstdeklaration
  genügt, deutsch als Muttersprache zu sprechen&lt;/li&gt;
&lt;li&gt;Ein Formular über die Erfüllung der Einbürgerungsvoraussetzungen&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="zusammenstellung-der-dokumente"&gt;Zusammenstellung der Dokumente&lt;/h2&gt;
&lt;p&gt;Die meisten Dokumente sind schnell zusammengetragen:
Wohnsitzbestätigungen und Betreibungsregisterauszüge lassen sich bei
den meisten Gemeinden online bestellen.  Ausweiskopien,
Arbeitgeberbestätigung und Ausbildungsnachweis sind ebenfalls schnell
parat.  Der Steuerausweis kann gemäss Information auf der Checkliste
erst nach Erhalt des Zivilstands-Registersauszugs bestellt werden.&lt;/p&gt;
&lt;p&gt;Das Gesuchsformular und Formular über die Einbürgerungsvoraussetzungen
sind ziemlich umfangreich, man muss unter anderem Angaben machen zu:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Ehemaligen Wohnsitze innerhalb und ausserhalb der Schweiz&lt;/li&gt;
&lt;li&gt;Die Ausbildungs- und Schullaufbahn zurück bis zum Kindergarten&lt;/li&gt;
&lt;li&gt;Art der finanziellen Absicherung (Berufstätigkeit, Rente, etc.)&lt;/li&gt;
&lt;li&gt;Vorstrafen, Gerichtsverfahren und/oder unbeglichen Schulden&lt;/li&gt;
&lt;li&gt;Teilnahme am sozialen Leben (z.B. Vereine, Hobbies)&lt;/li&gt;
&lt;li&gt;Art der Kontakte zu Schweizer Bürger*innen&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Zudem muss man 4 Referenzpersonen angeben, die - mit Ausnahme von
Vorgesetzten oder Lehrpersonen - das schweizer Bürgerrecht haben
müssen.&lt;/p&gt;
&lt;h2 id="besuch-auf-dem-zivilstandsamt"&gt;Besuch auf dem Zivilstandsamt&lt;/h2&gt;
&lt;p&gt;Der Registerauszug des Zivilstandsamts muss auch trotz Pandemie
persönlich vor Ort bestellt werden.&lt;/p&gt;
&lt;p&gt;Mein erster Besuch auf dem Zivilstandsamt ging ziemlich schnell und
war ziemlich informativ: Um den Auszug bestellen zu können, muss ich
erst noch ein paar Dokumente einreichen...  Das meiste davon hatte ich
schon parat, allerdings musste ich noch eine aktuelle Geburtsurkunde
von meinem Geburtsort in Deutschland bestellen.  Die Sachbearbeiterin
gab mir noch eine Checkliste mit, und damit war das Gespräch auch
schon erledigt.  Zudem hatte sie mir noch den Tipp gegeben, mit dem
Bestellen von anderen Dokumenten wie Wohnsitzbestätigungen abzuwarten,
da die Eintragung im Zivilstandsregister und anschliessende Erstellung
des Auszugs einige Wochen dauern könne.&lt;/p&gt;
&lt;p&gt;Nach einigen Tagen hatte ich die Geburtsurkunde im Briefkasten, und
bin zum zweiten Mal aufs Zivilstandsamt, dieses mal ausgestattet mit
allen benötigten Dokumenten und den Gebühren in bar.&lt;/p&gt;
&lt;h2 id="einreichung-des-gesuchs"&gt;Einreichung des Gesuchs&lt;/h2&gt;
&lt;p&gt;5 Wochen nach der Bestellung auf dem Zivilstandsamt wurde ich nochmal
via E-Mail eingeladen, um die erfassten Daten nochmal zu bestätigen
und den Registerauszug abzuholen.  Danach musste ich noch den
Steuerausweis beim Finanzdepartement des Kantons bestellen, dieser war
trotz Bestellung auf dem Postweg aber bereits 2 Tage später im
Briefkasten.&lt;/p&gt;
&lt;p&gt;Nun hatte ich endlich alle Unterlagen zusammengetragen, und habe das
Einbürgerungsgesuch via Post ans Migrationsamt geschickt.  Seit der
ersten Beratung hat es fast 10 Wochen gedauert, um die benötigten
Dokumente zu besorgen.  Aber das lange Warten beginnt erst jetzt...&lt;/p&gt;</content><category term="life"/><category term="Einbürgerung"/><category term="Schweiz"/><category term="Basel"/></entry><entry><title>Mein Einbürgerungverfahren in Basel - Teil 1: Intro</title><link href="https://s3lph.me/einbuergerungsverfahren-in-basel-teil-1-intro-de.html" rel="alternate"/><published>2021-09-20T13:15:00+02:00</published><updated>2021-09-20T13:15:00+02:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2021-09-20:/einbuergerungsverfahren-in-basel-teil-1-intro-de.html</id><summary type="html">&lt;p&gt;Letzte Woche habe ich mein Einbürgerungsgesuch beim Migrationsamt in
Basel eingereicht.  Damit beginnt für mich das langwierige (1.5-2
Jahre) Einbürgerungsverfahen, über das ich hier im Blog berichten
werde, da ich bisher kaum ausführliche Erfahrungsberichte im Internet
finden konnte, und quasi gar keine aus Basel.&lt;/p&gt;
&lt;h2 id="hintergrund"&gt;Hintergrund&lt;/h2&gt;
&lt;p&gt;Ich lebe seit 10 …&lt;/p&gt;</summary><content type="html">&lt;p&gt;Letzte Woche habe ich mein Einbürgerungsgesuch beim Migrationsamt in
Basel eingereicht.  Damit beginnt für mich das langwierige (1.5-2
Jahre) Einbürgerungsverfahen, über das ich hier im Blog berichten
werde, da ich bisher kaum ausführliche Erfahrungsberichte im Internet
finden konnte, und quasi gar keine aus Basel.&lt;/p&gt;
&lt;h2 id="hintergrund"&gt;Hintergrund&lt;/h2&gt;
&lt;p&gt;Ich lebe seit 10 Jahren in der Schweiz, bin aber in Deutschland
geboren und deutscher Staatsbürger.  In Basel lebe ich seit zwei
Jahren, vorher habe ich im Kanton Aargau (zwischen Basel und Zürich)
gelebt.&lt;/p&gt;
&lt;p&gt;Als Ausländer mit einer Niederlassungsbewilligung habe ich
weitestgehend die gleichen Rechte wie Schweizer Staatsbürger, darf
aber z.B. nicht an Wahlen und Abstimmungen teilnehmen.  Mit der
Einbürgerung entfallen diese Restriktionen, dafür kommen aber auch
neue Verpflichtungen dazu.&lt;/p&gt;
&lt;h2 id="der-theorie-teil"&gt;Der Theorie-Teil&lt;/h2&gt;
&lt;p&gt;Das Bürgerrecht in der Schweiz gliedert sich in drei Teile:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Das Staatsbürgerrecht der Schweiz&lt;/li&gt;
&lt;li&gt;Das Kantonsbürgerrecht eines Kantons&lt;/li&gt;
&lt;li&gt;Das Gemeindebürgerrecht einer Gemeinde&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Den Kantons- und Gemeindebürgerrechten kommt heutzutage nur noch wenig
praktische Bedeutung zu, trotzdem können alle drei Rechte nur
gemeinsam erlangt werden.  Entsprechend aufwändig ist das
Einbürgerungsverfahren, da es sowohl vom Kanton, der Gemeinde und dem
Bund bearbeitet wird, und jede Ebene eigene Voraussetzungen für die
Erteilung des ihres jeweiligen Bürgerrechts festlegt.&lt;/p&gt;
&lt;p&gt;Dementsprechend bezieht sich mein Einbürgerungsgesuch auf das&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Schweizer Staatsbürgerrecht&lt;/li&gt;
&lt;li&gt;Kantonsbürgerrecht des Kantons Basel-Stadt&lt;/li&gt;
&lt;li&gt;Gemeindebürgerrecht der Stadt Basel&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Die eben erwähnten Anforderungen sind zum Beispiel:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Für das Staatsbürgerrecht muss man min. 10 Jahre in der Schweiz
  gelebt haben und min. eine der Landessprachen sprechen.  Zudem muss
  man darlegen, dass man finanziell abgesichert ist, keine Sozialhilfe
  bezieht und keine Steuerschulden hat.&lt;/li&gt;
&lt;li&gt;Der Kanton Basel-Stadt verlangt den Wohnsitz im Kanton während der
  letzten 2 Jahre, und spezifiziert, dass man deutsch sprechen können
  muss.&lt;/li&gt;
&lt;li&gt;Die Bürgergemeinde der Stadt Basel prüft, ob die Gesuchsstellenden
  in der Schweiz und im Kanton integriert sind, und führt ein
  entsprechendes Einbürgerungsgespräch.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Im Gegensatz zu diversen anderen Kantonen gibt es in Basel-Stadt
keinen schriftlichen Einbürgerungstest, stattdessen werden
entsprechende Kenntnisse im Einbürgerungsgespräch der Bürgergemeinde
abgefragt.&lt;/p&gt;</content><category term="life"/><category term="Einbürgerung"/><category term="Schweiz"/><category term="Basel"/></entry><entry><title>Extracting 3D Models From CesiumJS - Part 2: Terrain Map Parsing</title><link href="https://s3lph.me/extracting-3dmodels-from-cesium-part-2-terrain-map-parsing.html" rel="alternate"/><published>2021-05-09T00:00:00+02:00</published><updated>2021-05-09T00:00:00+02:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2021-05-09:/extracting-3dmodels-from-cesium-part-2-terrain-map-parsing.html</id><summary type="html">&lt;p&gt;&lt;a href="https://github.com/CesiumGS/cesium"&gt;CesiumJS&lt;/a&gt; is a open source JavaScript framework for
rendering 2D and 3D maps - everything from a local area to whole
planets - in a web browser using WebGL.  In the past few weeks I've
been working on obtaining 3D model data in a situation where the only
easily available way of …&lt;/p&gt;</summary><content type="html">&lt;p&gt;&lt;a href="https://github.com/CesiumGS/cesium"&gt;CesiumJS&lt;/a&gt; is a open source JavaScript framework for
rendering 2D and 3D maps - everything from a local area to whole
planets - in a web browser using WebGL.  In the past few weeks I've
been working on obtaining 3D model data in a situation where the only
easily available way of accessing the data is through a CesiumJS based
viewer.  As far as I know, Cesium deals with two different kinds of 3D
data: On one side, there's 3D models used for small-scale objects like
buildings or trees, on the other side there's terrain maps.&lt;/p&gt;
&lt;h2 id="the-quantized-mesh-file-format"&gt;The quantized-mesh File Format&lt;/h2&gt;
&lt;p&gt;In the &lt;a href="extracting-3dmodels-from-cesium-part-1-terrain-map-scraping"&gt;previous article&lt;/a&gt;, I wrote about how to scrape
&lt;a href="https://github.com/CesiumGS/quantized-mesh"&gt;quantized-mesh&lt;/a&gt;-formatted terrain map tiles from a CesiumJS
web app.  At the end of the previous article, we were left with a
bunch of &lt;code&gt;.terrain&lt;/code&gt; files.&lt;/p&gt;
&lt;p&gt;Luckily, the quantized-mesh format is documented fairly well in the
&lt;a href="https://github.com/CesiumGS/quantized-mesh"&gt;specification&lt;/a&gt;:&lt;/p&gt;
&lt;p&gt;The file starts with an 88-byte header consisting of:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The center of the tile in earth-centered Cartesian coordinates.&lt;/li&gt;
&lt;li&gt;The height range covered by this tile, as distance from the earth's
   center.  You'll see later why this is required.&lt;/li&gt;
&lt;li&gt;The parameters (center and radius) of the tile's bounding sphere.&lt;/li&gt;
&lt;li&gt;Coordinates of a «horizon occlusion point», used to simplify
   rendering: The point is chosen so that if it lies below the
   horizon, the entire tile is behind the horizon, and thus
   invisible, and does not need to be rendered.  Cesium wrote an
   entire &lt;a href="https://cesium.com/blog/2013/04/25/horizon-culling/"&gt;blogpost&lt;/a&gt; about this.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;After the header follows the vertex data consisting of:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The number of vertices.&lt;/li&gt;
&lt;li&gt;An array of horizontal coordinates, 0 means westmost, 32767
   eastmost, everything in between is linearly interpolated.&lt;/li&gt;
&lt;li&gt;An array of vertical coordinates, 0 means southmost, 32767 northmost.&lt;/li&gt;
&lt;li&gt;An array of heights.  Here the height range mentioned earlier comes
   in: 0 encodes the lower height bound, and 32767 encodes the upper
   bound.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The vertex data (the contents of the three arrays) follows a rather
odd encoding with two specialties:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Each vertex' data is not encoded as absolute values, but as deltas
  to the data of the preceding vertex.  Only the first vertex' data is
  encoded with absolute values.&lt;/li&gt;
&lt;li&gt;The individual numbers (unsigned 16-bit integers) are not encoded as
  two's complement, as is common in today's computing, but as
  something Cesium calls "zig-zag encoding": The sequence of encoded
  numbers alternatingly encode positive and negative numbers, as
  depicted in &lt;a href="#figure1"&gt;Figure 1&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Luckily, the quantized-mesh specification provides pseudocode for
decoding this data.&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2021-05-09-cesium-qm-zigzag.png"&gt;
    &lt;img src="https://s3lph.me/images/2021-05-09-cesium-qm-zigzag.png"
         title="quantized-mesh zig-zag encoding visualized on a number line."
         alt="quantized-mesh zig-zag encoding visualized on a number line."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure1" class="figureid"&gt;Figure 1:&lt;/a&gt;
    quantized-mesh zig-zag encoding visualized on a number line.  The
    unencoded, "real" numbers are shown in black on top, the zig-zag
    encoded data in green on the bottom.
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;The specification states that this encoding is chosen «in order to
make small integers, regardless of their sign, use a small number of
bits».&lt;/p&gt;
&lt;p&gt;I'm not entirely sure why they want to achieve this (after all, each
number takes up the same amount of space, no matter how many bits are
zero), but I suspect this is done to potentially further reduce the
resulting size if the file is compressed, esp. when using HTTP's gzip
transport compression.&lt;/p&gt;
&lt;p&gt;Following the vertex data is the index data: Similar to a lot other
file formats dealing with 3D data, a quantized-mesh file ultimately
encodes a set of polygons, or to be more precise, triangles.  The
index data describes a series of triangles trough:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The number of triangles.&lt;/li&gt;
&lt;li&gt;An array of vertex indices, with three indices per triangle, one
   for each corner of a triangle.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;A vertex index is simply the index of a vertex in the aforementioned
vertex data.  This indirection is used because most vertices are part
of multiple triangles, so some space can be saved.  In addition, the
index data is again encoded somewhat specially (but a different kind
of special than the vertex data).  I did not look too deep into
understanding what exactly is done and why it is done, but again I
assume this is to make the file more compressible.&lt;/p&gt;
&lt;p&gt;Depending on how many vertices the file contains, the indices are
encoded as either 16-bit or 32-bit integers, and padding is inserted
before the index data to ensure memory alignment.&lt;/p&gt;
&lt;p&gt;Finally, the file contains four more vertex index lists, describing
which vertices are part of each of the four edges (western, southern,
eastern, northern) of the terrain tile.&lt;/p&gt;
&lt;p&gt;Optionally, there can be some extension data added to the end of the
file, such as normal vectors for lighting computation when rendering
the terrain, but this is beyond the scope of this post.&lt;/p&gt;
&lt;p&gt;To sum it up, &lt;a href="#figure2"&gt;Figure 2&lt;/a&gt; shows an illustration of
the file format and which parts encode which features of a 3D terrain.&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2021-05-09-cesium-qm-3dviz.png"&gt;
    &lt;img src="https://s3lph.me/images/2021-05-09-cesium-qm-3dviz.png"
         title="quantized-mesh file format visualization with an exaple terrain."
         alt="quantized-mesh file format visualization with an exaple terrain."&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure2" class="figureid"&gt;Figure 2:&lt;/a&gt; Visualization of the
    quantized-mesh file format with an example terrain file.  The
    left-hand side depicts the sections of a quantized-mesh terrain
    file, the right-hand side shows a 3D terrain rendering with certain
    features highlighted.  The arrows indicate which section describes
    which type of feature.
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h2 id="parsing-the-quantized-mesh-files"&gt;Parsing The quantized-mesh Files&lt;/h2&gt;
&lt;p&gt;I wrote a simple converter script that parses a quantized-mesh file
and creates an ASCII STL file containing the terrain.  The STL
coordinates are in meters, using Cartesian coordinates with the origin
in the Earth's center.  To be more precise, the coordinates are in
&lt;a href="https://epsg.io/4978"&gt;EPSG:4978&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;You can find the script on &lt;a href="https://gitlab.com/-/snippets/2054419"&gt;Gitlab&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The next article in this series will cover the scraping of 3D building
models.&lt;/p&gt;</content><category term="hacks"/><category term="CesiumJS"/><category term="3D Models"/><category term="Parsing"/></entry><entry><title>Mailinglist Archive URL Redirection from Pipermail to Hyperkitty</title><link href="https://s3lph.me/mailinglist-archive-url-redirection-from-pipermail-to-hyperkitty.html" rel="alternate"/><published>2021-03-17T01:00:00+01:00</published><updated>2021-03-17T01:00:00+01:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2021-03-17:/mailinglist-archive-url-redirection-from-pipermail-to-hyperkitty.html</id><summary type="html">&lt;p&gt;I recently migrated a mailing list setup running on Mailman 2 to a new
&lt;a href="http://www.list.org/"&gt;Mailman 3&lt;/a&gt;-based setup on a new server.  The migration itself is
pretty straight forward, especially thanks to the &lt;a href="https://docs.mailman3.org/en/latest/migration.html"&gt;official upgrade
guide&lt;/a&gt;.  The migration from Pipermail (the default archiver used
in Mailman 2) to Hyperkitty (Mailman …&lt;/p&gt;</summary><content type="html">&lt;p&gt;I recently migrated a mailing list setup running on Mailman 2 to a new
&lt;a href="http://www.list.org/"&gt;Mailman 3&lt;/a&gt;-based setup on a new server.  The migration itself is
pretty straight forward, especially thanks to the &lt;a href="https://docs.mailman3.org/en/latest/migration.html"&gt;official upgrade
guide&lt;/a&gt;.  The migration from Pipermail (the default archiver used
in Mailman 2) to Hyperkitty (Mailman 3 archiver) is easy as well, with
Hyperkitty providing a command to import the per-list mbox files
maintained by Mailman 2.&lt;/p&gt;
&lt;p&gt;However there is one thing that will break: Hyperlinks to the old
setup can not all be trivially translated to the new URLs.  This is
why I came up with the following solution to provide a low-effort
redirection service that will get most users to the correct new
destination.&lt;/p&gt;
&lt;h2 id="urls-to-redirect"&gt;URLs to Redirect&lt;/h2&gt;
&lt;p&gt;Let's first have a look at what kind of URLs are used in the old setup
so we can figure out where to redirect them. &lt;/p&gt;
&lt;p&gt;Before we start, note that all list-specific Mailman 3 URLs include
the full address of the list, not just the local part.  You need to
pay special attention here if you're operating mailing lists for
multiple domains.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;/cgi-bin/mailman/listinfo&lt;/code&gt; shows a list of all publicly visible
  lists. This can simply be redirected to the Mailman 3 root URL, i.e.
  &lt;code&gt;/&lt;/code&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;/cgi-bin/mailman/listinfo/test&lt;/code&gt; is the welcome page of the &lt;code&gt;test&lt;/code&gt;
  mailing list.  It shows the list info and the subscription
  interface.  This can trivially be redirected to the Mailman 3 list
  welcome page, &lt;code&gt;/postorius/lists/test.example.org/&lt;/code&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I chose to simply redirect every "verb" (the &lt;code&gt;listinfo&lt;/code&gt; part) to the
  welcome page, as most, if not all, other verbs are only to be used
  by list admins, and they should ideally already know the new
  location.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;/pipermail&lt;/code&gt; is the archive landing page listing all public mailing
  list archives.  This can simply be redirected to &lt;code&gt;/hyperkitty&lt;/code&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;/pipermail/test&lt;/code&gt; is the archive overview of the &lt;code&gt;test&lt;/code&gt; mailing
  list.  Redirect to &lt;code&gt;/hyperkitty/list/test@example.com&lt;/code&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;There is a bunch of per-month overviews, such as
  &lt;code&gt;/pipermail/test/2021-March/thread.html&lt;/code&gt; for a threaded overview of
  the posts from March 2021, or
  &lt;code&gt;/pipermail/test/2021-March/author.html&lt;/code&gt; for an overview grouped by
  author.  As Hyperkitty only has a monthly overview by thread, I
  redirect all of these overviews to the same Hyperkitty monthly
  overview page, e.g. &lt;code&gt;/hyperkitty/list/test@example.org/2021/3&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The timestamp in the URL is definitely human readable, however this
  is the first obstacle that requires a bit more effort. Of course
  this could be solved with 12 individual rewrites, each for one
  month, I chose to incorporate the redirection for this in my
  solution for the next item.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Finally, there are links to individual messages, such as
  &lt;code&gt;/pipermail/test/2021-March/001337.html&lt;/code&gt;.  These turned out to be
  the greatest challenge: Pipermail uses sequential numbers for
  addressing individual messages, while Hyperkitty uses a hash of the
  message's &lt;code&gt;Message-Id&lt;/code&gt; header,
  e.g. &lt;code&gt;/hyperkitty/list/test@example.org/message/7HPT35IASDHNIW6MGBDEDR4LENSIR5F4&lt;/code&gt;.
  Unfortunately, there exists no trivial mapping between these two
  methods, so I wrote a small PHP script to perform the redirection.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="redirecting-individual-message-urls"&gt;Redirecting Individual Message URLs&lt;/h2&gt;
&lt;p&gt;As mentioned before, Pipermail uses sequential numbers to identify
single messages, while Hyperkitty bases its IDs on the Message-Id
header.  Unfortunately, while generating the archives, Pipermail
removes all information that would be required to match the sequential
ID to a Message-Id:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The HTML files know about the sequential ID, but not the Message-Id.&lt;/li&gt;
&lt;li&gt;The mbox files and textfile archives contain the Message-Id, but not
  the sequential ID assigned by Pipermail.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And even if they did, I didn't want to keep the old archives around
anyway, and only provide redirection based on the URLs.  So how can we
achieve this?&lt;/p&gt;
&lt;p&gt;As it turns out, when importing the mbox file into Hyperkitty, the
order in which the messages are inserted into the database backend of
Hyperkitty is the same order in which Pipermail assigns the IDs.  Of
course this only works flawlessly iff &lt;strong&gt;all&lt;/strong&gt; messages in the mbox
file were successfully processed by &lt;strong&gt;both&lt;/strong&gt; Pipermail &lt;strong&gt;and&lt;/strong&gt;
Hyperkitty.  Otherwise a skew is introduced.  However, I only
encountered this issue a few times, so I chose to simply correct this
manually.&lt;/p&gt;
&lt;p&gt;I came up with a little PHP script that takes the list name and the
sequential ID as arguments, and looks up the (hopefully) matching
message ID in the Hyperkitty database.  The SQL query used for this
simply fetches a single message ID hash from the row that contains the
message with the specified offset:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message_id_hash&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;hyperkitty_email&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;hyperkitty_mailinglist&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mailinglist_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;listid&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;postid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;The script takes some additional parameters for manual offset
correction.  It additionally takes care of Pipermails &lt;code&gt;2021-March&lt;/code&gt;
timestamps by parsing them with the appropriate &lt;code&gt;srtptime&lt;/code&gt;
formatter. You can find the script along with a sample Apache rewrite
configuration on &lt;a href="https://gitlab.com/-/snippets/2091508"&gt;Gitlab&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="apache-rewrite-rules"&gt;Apache Rewrite Rules&lt;/h2&gt;
&lt;p&gt;To perform all the static redirects explained above, as well as invoke
the PHP script, I configured Apache 2 somewhat like this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="c"&gt;# Exempt the conversion script from proxying to mailman3-web&lt;/span&gt;
&lt;span class="nb"&gt;ProxyPass&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="sx"&gt;/pipermail2hyperkitty.php&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;!
&lt;span class="nb"&gt;ProxyPass&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;/&lt;span class="w"&gt; &lt;/span&gt;unix:/run/mailman3-web/uwsgi.sock|uwsgi://localhost/


&lt;span class="nb"&gt;RewriteEngine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;on&lt;/span&gt;

&lt;span class="c"&gt;#&lt;/span&gt;
&lt;span class="c"&gt;# List info pages get rewritten directly through mod_rewrite&lt;/span&gt;
&lt;span class="c"&gt;#&lt;/span&gt;

&lt;span class="c"&gt;# Global list overview page&lt;/span&gt;
&lt;span class="nb"&gt;RewriteRule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;^/cgi-bin/mailman/listinfo/?$&lt;span class="w"&gt; &lt;/span&gt;/

&lt;span class="c"&gt;# Individual list overview pages&lt;/span&gt;
&lt;span class="c"&gt;# some lists may need special handling, e.g. due to renaming or a non-default domain&lt;/span&gt;
&lt;span class="nb"&gt;RewriteRule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;^/cgi-bin/mailman/[^/]+/oldlist(/.*)?$&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="sx"&gt;/postorius/lists/newlist.example.net&lt;/span&gt;
&lt;span class="c"&gt;# Catch-all for all other lists&lt;/span&gt;
&lt;span class="nb"&gt;RewriteRule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;^/cgi-bin/mailman/[^/]+/([^/]+)(/.*)?$&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="sx"&gt;/postorius/lists/&lt;/span&gt;$1.example.org

&lt;span class="c"&gt;#&lt;/span&gt;
&lt;span class="c"&gt;# All archive URLs get handed over to pipermail2hyperkitty.php&lt;/span&gt;
&lt;span class="c"&gt;#&lt;/span&gt;

&lt;span class="c"&gt;# Special handling for some lists, e.g. renamed lists or non-default domains&lt;/span&gt;
&lt;span class="nb"&gt;RewriteRule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;^/pipermail/oldlist/([^/]+)/([^./]+)((\.|/).*)?$&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="sx"&gt;/pipermail2hyperkitty.php&lt;/span&gt;?listid=newlist@examle.net&amp;amp;ym=$1&amp;amp;postid=$2&lt;span class="w"&gt; &lt;/span&gt;[L,PT,QSD]
&lt;span class="nb"&gt;RewriteRule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;^/pipermail/oldlist/([^./]+)((\.|/).*)?$&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="sx"&gt;/pipermail2hyperkitty.php&lt;/span&gt;?listid=newlist@example.net&amp;amp;ym=$1&lt;span class="w"&gt; &lt;/span&gt;[L,PT,QSD]
&lt;span class="nb"&gt;RewriteRule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;^/pipermail/oldlist((\.|/).*)?$&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="sx"&gt;/pipermail2hyperkitty.php&lt;/span&gt;?listid=newlist@example.net&lt;span class="w"&gt; &lt;/span&gt;[L,PT,QSD]
&lt;span class="c"&gt;# The newlist@example.net archive was merged with the oldlist archives, need to know the db offset (= number of posts in oldlist)&lt;/span&gt;
&lt;span class="nb"&gt;RewriteRule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;^/pipermail/newlist/([^/]+)/([^./]+)((\.|/).*)?$&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="sx"&gt;/pipermail2hyperkitty.php&lt;/span&gt;?listid=newlist@examle.net&amp;amp;ym=$1&amp;amp;postid=$2&amp;amp;idoffset=1337&lt;span class="w"&gt; &lt;/span&gt;[L,PT,QSD]
&lt;span class="c"&gt;# The messages 23 through 42 are missing from the test mailinglist archives, skew needs to be compensated&lt;/span&gt;
&lt;span class="nb"&gt;RewriteRule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;^/pipermail/test/([^/]+)/([^./]+)((\.|/).*)?$&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="sx"&gt;/pipermail2hyperkitty.php&lt;/span&gt;?listid=test@example.org&amp;amp;ym=$1&amp;amp;postid=$2&amp;amp;missing=23_42&lt;span class="w"&gt; &lt;/span&gt;[L,PT,QSD]

&lt;span class="c"&gt;# Catch-all for all other lists&lt;/span&gt;
&lt;span class="c"&gt;# Everything containing a list name, timestamp and sequential message ID -&amp;gt; to the single message&lt;/span&gt;
&lt;span class="nb"&gt;RewriteRule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;^/pipermail/([^/]+)/([^/]+)/([^./]+)((\.|/).*)?$&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="sx"&gt;/pipermail2hyperkitty.php&lt;/span&gt;?listid=$1@example.org&amp;amp;ym=$2&amp;amp;postid=$3&lt;span class="w"&gt; &lt;/span&gt;[L,PT,QSD]
&lt;span class="c"&gt;# Everything containing a list name and timestamp, but no sequential message ID -&amp;gt; to the month&amp;#39;s overview page&lt;/span&gt;
&lt;span class="nb"&gt;RewriteRule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;^/pipermail/([^/]+)/([^./]+)((\.|/).*)?$&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="sx"&gt;/pipermail2hyperkitty.php&lt;/span&gt;?listid=$1@example.org&amp;amp;ym=$2&lt;span class="w"&gt; &lt;/span&gt;[L,PT,QSD]
&lt;span class="c"&gt;# Everything containing a list name, but no timestamp or sequential message ID -&amp;gt; to the list&amp;#39;s overview page&lt;/span&gt;
&lt;span class="nb"&gt;RewriteRule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;^/pipermail/([^/]+)((\.|/).*)?$&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="sx"&gt;/pipermail2hyperkitty.php&lt;/span&gt;?listid=$1@example.org&lt;span class="w"&gt; &lt;/span&gt;[L,PT,QSD]
&lt;span class="c"&gt;# Everything else -&amp;gt; to the global archive overview&lt;/span&gt;
&lt;span class="nb"&gt;RewriteRule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;^/pipermail((\.|/).*)?$&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="sx"&gt;/hyperkitty&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;[L,PT,QSD]
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;This is by no means a perfect, bulletproof, or even good solution.
And it does not intend to be.  I only wrote this script to ease the
migration phase for users, and it performs that job well enough.&lt;/p&gt;</content><category term="hacks"/><category term="Mailinglists"/><category term="Migration"/><category term="Pipermail"/><category term="Hyperkitty"/><category term="PHP"/><category term="Apache 2"/><category term="mod_rewrite"/></entry><entry><title>Migration of IRC Services from Hybserv2 to Anope</title><link href="https://s3lph.me/migration-of-irc-services-from-hybserv2-to-anope.html" rel="alternate"/><published>2020-12-23T23:00:00+01:00</published><updated>2020-12-23T23:00:00+01:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2020-12-23:/migration-of-irc-services-from-hybserv2-to-anope.html</id><summary type="html">&lt;p&gt;I recently inherited a small IRC network, consisting of a single
server and a rather small but still active community.  The software on
this server was quite outdated, running a version of
&lt;a href="https://github.com/ircd-hybrid/ircd-hybrid/"&gt;ircd-hybrid&lt;/a&gt; from 8 years ago, and for IRC services a
comparably old version of the now-unmaintained &lt;a href="https://github.com/dkorunic/hybserv2/"&gt;hybserv2&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I …&lt;/p&gt;</summary><content type="html">&lt;p&gt;I recently inherited a small IRC network, consisting of a single
server and a rather small but still active community.  The software on
this server was quite outdated, running a version of
&lt;a href="https://github.com/ircd-hybrid/ircd-hybrid/"&gt;ircd-hybrid&lt;/a&gt; from 8 years ago, and for IRC services a
comparably old version of the now-unmaintained &lt;a href="https://github.com/dkorunic/hybserv2/"&gt;hybserv2&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I decided to not even attempt to get this stuff up to date, but rather
replace the server entirely with recent and maintained software.  The
choice fell on &lt;a href="https://www.inspircd.org/"&gt;InspIRCd 3&lt;/a&gt; and &lt;a href="https://anope.org/"&gt;Anope 2&lt;/a&gt;.  Switching
to a new IRC server is not really that much of a challenge, especially
in a single-server network.  There is some minor tweaking required,
such as making user and channel modes compatible with each other
(e.g. InspIRCd 3 &lt;a href="https://docs.inspircd.org/3/breaking-changes/"&gt;dropped core support for the halfop (+h) channel
mode&lt;/a&gt;, which can hovever be reintroduced with the
"customprefix" module).  The far greater challenge were the IRC
services, as there are some major incompatibilities:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Hybserv2 stores password hashes using the original &lt;a href="https://en.wikipedia.org/wiki/Crypt_(C)"&gt;crypt algorithm
   based on DES&lt;/a&gt;.  It would support MD5 instead of DES as
   well, but that would have to have been enabled beforehand.  Anope
   on the other hand, doesn't support DES.  They offer various
   variants of crypt-style hashes as well, but the "least modern" of
   those is MD5.&lt;/li&gt;
&lt;li&gt;Both Hybserv and Anope store their persistent data in plain text
   files (Anope does support other methods, such as database backends,
   but recommends the text file), however the formats of these files
   are incompatible and neither format is (well) documented.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="password-hash-migration"&gt;Password Hash Migration&lt;/h2&gt;
&lt;p&gt;As it turns out, Anope has built-in functionality for password hash
migration: When more than one password hashing module (&lt;code&gt;enc_*&lt;/code&gt;) is
loaded, only the first one is used for hashing new passwords.  The
other modules are only used for verifying already existing hashes.  On
top of that, when a user successfully authenticates against NickServ,
and the hash is not in the default format (i.e. the first module),
Anope will automatically re-hash the password entered by the user
using the default method, and write it to disk.&lt;/p&gt;
&lt;p&gt;Using this method, the old DES hashes are replaced by more modern ones
as each user logs in, until all hashes have been converted (or the
NickServ registrations of inactive users have expired).  So the only
thing left to do is to write an Anope module to verify DES crypt
password hashes.  This is pretty straight forward, as all that's
needed is a thin wrapper around &lt;code&gt;crypt&lt;/code&gt; that conforms to the Anope
module API.  My implementation can be found on &lt;a href="https://gitlab.com/-/snippets/2053808"&gt;Gitlab&lt;/a&gt; along
with the rest of the sources.  To build the module, and including it
in your Anope installation, the following steps are required:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Clone the Anope repo from Github:
    &lt;code&gt;git clone https://github.com/anope/anope&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Checkout the exact version of Anope you intend to run.  In my case,
    this was version 2.0.9, so I'd do &lt;code&gt;git checkout 2.0.9&lt;/code&gt;.  This step
    is important, because Anope refuses to load modules built for
    another version.&lt;/li&gt;
&lt;li&gt;Place the &lt;code&gt;enc_des.cpp&lt;/code&gt; file in &lt;code&gt;modules/encryption/&lt;/code&gt; in the Anope
    repo and follow the Anope build instructions:&lt;ol&gt;
&lt;li&gt;&lt;code&gt;./Config&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;cd build&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;make&lt;/code&gt;.  Actually, since we only need the DES module, we can
   run &lt;code&gt;make enc_des.so&lt;/code&gt; instead, which will complete a lot
   faster.&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;Link against libcrypt.  Since I didn't want to deal with all the
    CMake stuff, I decided to just patch the .so file afterwards:
    &lt;code&gt;patchelf --add-needed libcrypt.so.1 modules/enc_des.so&lt;/code&gt;. Replace
    the actual version of libcrypt.so by whatever is available on your
    target system.&lt;/li&gt;
&lt;li&gt;Put the built module into your Anope installation's modules
    directory.  If you're using the Anope Debian package, this would
    be &lt;code&gt;/usr/lib/anope/modules/enc_des.so&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The final step left to do is to configure Anope to load the module:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="c1"&gt;// Used for new passwords and converting old passwords&lt;/span&gt;
&lt;span class="n"&gt;module&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;quot;enc_bcrypt&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// Used for verification only, passwords are automatically rehashed with bcrypt&lt;/span&gt;
&lt;span class="n"&gt;module&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;quot;enc_des&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;I chose to use bcrypt as the new hashing method.  You can choose
whichever method you like, however you must make sure that &lt;code&gt;enc_des&lt;/code&gt;
is listed &lt;strong&gt;after&lt;/strong&gt; the chosen module, otherwise all passwords would
be rehashed to DES crypt.&lt;/p&gt;
&lt;p&gt;And that's about it for password hashes.  You should then occasionally
check whether there are still any DES hashes left, and once they're
all gone, unload and remove the &lt;code&gt;enc_des&lt;/code&gt; module.&lt;/p&gt;
&lt;h2 id="understanding-hybservs-database"&gt;Understanding Hybserv's Database&lt;/h2&gt;
&lt;p&gt;Hybserv's "database" consists of multiple files: one for nicks, one
for channels, and so on.  In this specific case, only NickServ and
ChanServ were used, so there was no need to migrate the others.&lt;/p&gt;
&lt;p&gt;As mentioned before, there is no documentation of what the content in
these files looks like, so we'll have to figure this out on our own.&lt;/p&gt;
&lt;p&gt;A user in the &lt;code&gt;nick.db&lt;/code&gt; file looks like this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="n"&gt;s3lph&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;14600&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1602803962&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1608435182&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;PASS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;EelyBKlmXzO5r&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;EMAIL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s3lph&lt;/span&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="n"&gt;example&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;com&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;LASTUH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;s3lph&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bnc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;example&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;com&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;LASTQMSG&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;So&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;long&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;and&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;thanks&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;all&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;fish&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;HOST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*~&lt;/span&gt;&lt;span class="n"&gt;s3lph&lt;/span&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="n"&gt;bnc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;example&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;com&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;HOST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*~&lt;/span&gt;&lt;span class="n"&gt;s3lph&lt;/span&gt;&lt;span class="mf"&gt;@127.0.0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;TS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1608435182&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;LASTSERVER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;irc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;example&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;com&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;And this is a channel in the &lt;code&gt;chan.db&lt;/code&gt; file:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="cp"&gt;#example 4 1602804235 1603929600&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;FNDR&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s3lph&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1602804235&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;PASS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;wahF8nhZXc4D&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;TOPIC&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;This&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;is&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;an&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;example&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;ALVL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;ACCESS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s3lph&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1602804235&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1603929600&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="kr"&gt;As&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Founder&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;ACCESS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s4lph&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1602803962&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1608435256&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s3lph&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Each item is started with a line consisting of 4 fields: the name,
some number and two numbers that look like UNIX timestamps.  Looking
at Hybserv's &lt;a href="https://github.com/dkorunic/hybserv2/blob/master/src/nickserv.c#L714"&gt;source code&lt;/a&gt;, we learn that the first
number is a set of flags which control the services' behavior in
regard to the &lt;a href="https://github.com/dkorunic/hybserv2/blob/master/include/nickserv.h#L18"&gt;nick&lt;/a&gt; or
&lt;a href="https://github.com/dkorunic/hybserv2/blob/master/include/chanserv.h#L17"&gt;channel&lt;/a&gt;.  The source code also tells us that the
timestamps are the registration time and the time the resource was
last "seen".&lt;/p&gt;
&lt;p&gt;The following lines are mostly straight-forward.  They all start with
&lt;code&gt;-&amp;gt;&lt;/code&gt;, which seems to indicate continuation of the object.  They
feature "somewhat human readable" keys and one or more values
separated by spaces.  For NickServ registrations, there are values
such as the DES password hash, the email address, or which hostmasks
the user has successfully identified from before.  For ChanServ
registrations, we see values such as the channel topic or the founder.&lt;/p&gt;
&lt;p&gt;The last few lines were not entirely obvious, so again a look at the
&lt;a href="https://github.com/dkorunic/hybserv2/blob/master/src/chanserv.c#L643"&gt;source code&lt;/a&gt; provided clarification.  The &lt;code&gt;ALVL&lt;/code&gt; chain
of numbers turns out to be a definition of which
&lt;a href="https://github.com/dkorunic/hybserv2/blob/master/src/chanserv.c#L61"&gt;permissions&lt;/a&gt; (e.g. voice, halfop, chanop, founder)
are granted starting from which "access level".  This also clarifies
the format of the &lt;code&gt;ACCESS&lt;/code&gt; lines: After the nick comes the assigned
access level, then two timestamps (the time access was granted and the
time it was last used) and finally the nick of the user granting the
access.  In the example above, &lt;code&gt;s3lph&lt;/code&gt; would be the channel founder,
and &lt;code&gt;s4lph&lt;/code&gt; would be a halfop.&lt;/p&gt;
&lt;h2 id="understanding-anopes-database"&gt;Understanding Anope's Database&lt;/h2&gt;
&lt;p&gt;Anope uses a similar approach to storing data.  Again, data is stored
in plain text, however all data is stored in one file, anope.db,
instead of multiple files.  The format is actually quite similar, but
does feature some crucial differences (empty lines only inserted for
readability):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="n"&gt;OBJECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;NickCore&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;display&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s3lph&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;pass&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;des&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;EelyBKlmXzO5r&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;language&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;access&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s3lph&lt;/span&gt;&lt;span class="mf"&gt;@127.0.0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;memomax&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;HIDE_EMAIL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;HIDE_MASK&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;NS_PRIVATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;AUTOOP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;NS_SECURE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="n"&gt;END&lt;/span&gt;

&lt;span class="n"&gt;OBJECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;NickAlias&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;nick&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s3lph&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;last_quit&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;So&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;long&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;and&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;thanks&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;all&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;fish&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;last_realname&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Unknown&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;last_usermask&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s3lph&lt;/span&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="n"&gt;bnc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;example&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;com&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;last_realhost&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s3lph&lt;/span&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="n"&gt;bnc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;example&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;com&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;time_registered&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1602803962&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;last_seen&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1608435182&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;nc&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s3lph&lt;/span&gt;
&lt;span class="n"&gt;END&lt;/span&gt;


&lt;span class="n"&gt;OBJECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ChannelInfo&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="n"&gt;example&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;founder&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s3lph&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;time_registered&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1602804235&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;last_used&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1603929600&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;last_topic&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;This&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;is&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;an&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;example&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;last_topic_setter&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s3lph&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;last_topic_time&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1608173683&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bantype&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;levels&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ACCESS_CHANGE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ACCESS_LIST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;AKICK&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ASSIGN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10001&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;AUTOHALFOP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;AUTOOP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;AUTOOWNER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9999&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;AUTOPROTECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;AUTOVOICE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;BADWORDS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;BAN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;FANTASIA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;FOUNDER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;GETKEY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;HALFOP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;HALFOPME&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;INFO&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9999&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;INVITE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;KICK&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;MEMO&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;MODE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9999&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;NOKICK&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;OP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;OPME&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;OWNER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10001&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;OWNERME&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9999&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;PROTECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9999&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;PROTECTME&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;SAY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9999&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;SIGNKICK&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9999&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TOPIC&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;UNBAN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;VOICE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;VOICEME&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;banexpire&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;memomax&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;PEACE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;SECUREFOUNDER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;CS_SECURE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;SIGNKICK&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;KEEPTOPIC&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="n"&gt;END&lt;/span&gt;

&lt;span class="n"&gt;OBJECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ChanAccess&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;access&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;xop&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ci&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="n"&gt;example&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s4lph&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;creator&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s3lph&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;last_seen&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1608435256&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1602803962&lt;/span&gt;
&lt;span class="n"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;HOP&lt;/span&gt;
&lt;span class="n"&gt;END&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Due to the similarities, I'll only highlight the main differences:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Objects are typed (since all are kept in the same file) and are
    terminated by an explicit &lt;code&gt;END&lt;/code&gt; rather than by the start of the next
    object.&lt;/li&gt;
&lt;li&gt;There is no single number containing a lot of flags; instead each
    flag is represented by its own line (e.g. &lt;code&gt;HIDE_EMAIL&lt;/code&gt;, &lt;code&gt;NS_SECURE&lt;/code&gt;,
    ...)&lt;/li&gt;
&lt;li&gt;NickServ passwords are prefixed with the used hashing module.  I
    chose to use "des" as the prefix for my enc_des module.&lt;/li&gt;
&lt;li&gt;There are no more ChanServ passwords.  Instead, users are
    automatically granted their permissions once they identify through
    NickServ.&lt;/li&gt;
&lt;li&gt;Attributes that can occur multiple times in a Hybserv object are
    extracted into their own type.  For example, channel ACCESS
    attributes are now represented by ChanAccess objects.&lt;/li&gt;
&lt;li&gt;ChanAccess objects don't have a numeric access level, but a "data"
    attribute.  This can be one of VOP (voice), HOP (halfop), SOP
    (chanop) or QOP (founder).&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="textfile-database-conversion"&gt;Textfile Database Conversion&lt;/h2&gt;
&lt;p&gt;With the information gathered above, the conversion from one format to
another can be automated.  My conversion script can be found on
&lt;a href="https://gitlab.com/-/snippets/2053808"&gt;Gitlab&lt;/a&gt;.  However, you &lt;strong&gt;should not use it without careful
review&lt;/strong&gt; as there may be some subtle differences between my setup and
yours.  Especially the numeric access levels will most likely need to
be adapted on both the Hybserv as well as on the Anope side.  The
conversion can be done by following these steps:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;STOP ANOPE&lt;/strong&gt;.  Otherwise Anope will overwrite your changes the next
    time it flushes changes to disk.&lt;/li&gt;
&lt;li&gt;Find your anope.db file.  When using the Debian package, it's
    located under &lt;code&gt;/var/lib/anope/db/anope.db&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;MAKE A BACKUP&lt;/strong&gt; of the anope.db file.  The following two commands
    will append the converted data to anope.db.  If something goes
    wrong, you may lose existing Anope registrations.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;./hybserv2anope.py nickserv .../hybserv/nick.db anope.db&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;./hybserv2anope.py chanserv .../hybserv/chan.db anope.db&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;REVIEW anope.db&lt;/strong&gt;.  Make sure that everything looks as expected.&lt;/li&gt;
&lt;li&gt;Start Anope and observe the log for errors.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;The two migrations above (password hashes and database files) should
allow you to migrate from Hybserv2 to Anope without requiring any
action whatsoever from your users.  If everything went fine, you
should be able to log into NickServ and obtain ChanOp on your
channels.&lt;/p&gt;
&lt;p&gt;When you look into your anope.db, you will see that your DES password
hash was replaced by a bcrypt hash (or whatever method you chose).  If
it's not visible immediately, you can either wait some time, &lt;code&gt;/msg
OperServ UPDATE&lt;/code&gt; or restart Anope so that all changes are immediately
flushed to disk.  Once there are no more DES hashes left, unload the
&lt;code&gt;enc_des&lt;/code&gt; module.&lt;/p&gt;</content><category term="hacks"/><category term="IRC"/><category term="IRC Services"/><category term="Anope"/><category term="Hybserv"/><category term="Migration"/></entry><entry><title>Reminders with VoIP Phones, Asterisk &amp; Crontab</title><link href="https://s3lph.me/reminders-with-voip-phones-asterisk-crontab.html" rel="alternate"/><published>2020-09-11T00:00:00+02:00</published><updated>2020-09-11T00:00:00+02:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2020-09-11:/reminders-with-voip-phones-asterisk-crontab.html</id><summary type="html">&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Update (2020-10-30)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;As it turns out, this solution causes people to get used to the ringing phone,
causing not only the reminder to be ignored, but actual incoming calls as well.
We have thus stopped using this solution.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;It is considered best practice to regularly and often ventilate closed
rooms …&lt;/p&gt;</summary><content type="html">&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Update (2020-10-30)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;As it turns out, this solution causes people to get used to the ringing phone,
causing not only the reminder to be ignored, but actual incoming calls as well.
We have thus stopped using this solution.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;It is considered best practice to regularly and often ventilate closed
rooms due to SARS-CoV-2.  But how can we make sure we remember to open
the windows every now and then?  We wanted to build some kind of
visual or acoustic mechanism that would tell us when to open the
windows.&lt;/p&gt;
&lt;p&gt;I came up with the idea to abuse our already existing telephone system
as a reminder system.  This system consists of a central Asterisk
server, and multiple SIP phones.  The idea was to have Asterisk dial
all the SIP phones, and set the reminder text as caller ID.  The
"secret" behind this is Asterisk's console calling feature.  And here
is how it's done:&lt;/p&gt;
&lt;p&gt;First, we need to enable the &lt;code&gt;chan_alsa&lt;/code&gt; module in Asterisk's
&lt;code&gt;/etc/asterisk/modules.conf&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="k"&gt;[modules]&lt;/span&gt;
&lt;span class="na"&gt;load&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;&amp;gt; chan_alsa.so&lt;/span&gt;
&lt;span class="c1"&gt;; Comment out existing noload directives&lt;/span&gt;
&lt;span class="c1"&gt;;noload =&amp;gt; chan_alsa.so&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Afterwards restart Asterisk.&lt;/p&gt;
&lt;p&gt;In a default installation of Asterisk on Debian, this should be all
that's required for console calling to work.  In our case, Asterisk is
running in a VM and the default audio device is a virtual sound card
not used otherwise, so there was no need for &lt;a href="https://www.voip-info.org/asterisk-config-alsaconf/"&gt;additional
configuration&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Next, we can configure the dialplan in &lt;code&gt;/etc/asterisk/extensions.conf&lt;/code&gt;
for our reminders:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="k"&gt;[reminders]&lt;/span&gt;
&lt;span class="na"&gt;exten&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;&amp;gt; ventilate,1,NoOp()&lt;/span&gt;
&lt;span class="na"&gt;same&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;&amp;gt; n,Answer()&lt;/span&gt;
&lt;span class="c1"&gt;; Set the caller ID name to the reminder text&lt;/span&gt;
&lt;span class="na"&gt;same&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;&amp;gt; n,Set(CALLERID(name)=Lueften!)&lt;/span&gt;
&lt;span class="c1"&gt;; Dial all the phones&lt;/span&gt;
&lt;span class="na"&gt;same&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;&amp;gt; n,Dial(PJSIP/phone1&amp;amp;PJSIP/phone2)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;To apply this change, we have to reload the dialplan:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="gp"&gt;# &lt;/span&gt;asterisk&lt;span class="w"&gt; &lt;/span&gt;-rx&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;dialplan reload&amp;#39;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;We can then test our reminder by telling Asterisk to originate a call
from its console context:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="gp"&gt;# &lt;/span&gt;asterisk&lt;span class="w"&gt; &lt;/span&gt;-rx&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;originate console/dsp extension ventilate@reminders&amp;#39;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Now your phones should start ringing, and display your reminder text
as the caller ID.&lt;/p&gt;
&lt;p&gt;Finally, in order to schedule the reminders, just put them into the
crontab of the Asterisk server.  In our case, I configured it to ring
every full hour on Tuesdays between 8 and 11 PM:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;20&lt;/span&gt;-23&lt;span class="w"&gt; &lt;/span&gt;*&lt;span class="w"&gt; &lt;/span&gt;*&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;root&lt;span class="w"&gt; &lt;/span&gt;/usr/sbin/asterisk&lt;span class="w"&gt; &lt;/span&gt;-rx&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;originate console/dsp extension ventilate@reminders&amp;#39;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;</content><category term="hacks"/><category term="Telephone"/><category term="Asterisk"/><category term="VoIP"/></entry><entry><title>Cisco 7900 series IP Phone Logo Converter</title><link href="https://s3lph.me/cisco-7900-series-ip-phone-logo-converter.html" rel="alternate"/><published>2020-07-14T00:00:00+02:00</published><updated>2020-07-14T00:00:00+02:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2020-07-14:/cisco-7900-series-ip-phone-logo-converter.html</id><summary type="html">&lt;p&gt;Last week we discovered a box full of old Cisco 7900 series IP phones
hidden deep in a pile of boxes in our hackerspace.  Of course we tried
to get them up and running and figure out how to configure them.&lt;/p&gt;
&lt;p&gt;After a bit of research and reading manuals, we …&lt;/p&gt;</summary><content type="html">&lt;p&gt;Last week we discovered a box full of old Cisco 7900 series IP phones
hidden deep in a pile of boxes in our hackerspace.  Of course we tried
to get them up and running and figure out how to configure them.&lt;/p&gt;
&lt;p&gt;After a bit of research and reading manuals, we learned that the
phones can be configured via TFTP using a binary config file format.
When we learned that the phones can display a &lt;strong&gt;custom&lt;/strong&gt; 88x27
monochrome image instead of the default Cisco logo, we made it our top
priority to get the image customization working.&lt;/p&gt;
&lt;p&gt;What we learned was that the images had to be served alongside the
configuration via TFTP in a proprietary format.  The tool to convert
images into this format comes bundled with every firmware release for
the phone.  However, we quickly encountered some limitations:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The converter, called &lt;code&gt;bmp2logo.exe&lt;/code&gt;, was a proprietary, Windows
  only piece of software.  Luckily, the tool ran in Wine without any
  problems (as long as you don't mess with the input file header).&lt;/li&gt;
&lt;li&gt;The tool required the input file to be in a &lt;strong&gt;very&lt;/strong&gt; specific
  format, namely a 88x27 pixel, monochrome, 1 bit per pixel BMP file.&lt;/li&gt;
&lt;li&gt;While the image is drawn as dark pixels on a light background, the
  input file had to be white on black.  Effectively, the colors were
  inverted.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We also learned that each image had to be tagged with a serial number.
The phone uses this serial number to decide whether a new image has to
be loaded from the TFTP server.  If the serial number is the same as
the number of the previous image, the new image is not loaded.  So the
serial number needs to be incremented by at least 1 for each new
image.&lt;/p&gt;
&lt;p&gt;To get around the limitations of the converter tool, I attempted to
figure out the file format and write a free and cross-platform
converter without these limitations.&lt;/p&gt;
&lt;h2 id="reverse-engineering-the-file-format"&gt;Reverse Engineering the File Format&lt;/h2&gt;
&lt;p&gt;So let's have a look at a hexdump of one of those image files
converted with &lt;code&gt;bmp2logo.exe&lt;/code&gt;:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://s3lph.me/images/2020-07-14-cisco-7900-image-hexdump.png"&gt;&lt;img alt="Hexdump of a converted image file" src="https://s3lph.me/images/2020-07-14-cisco-7900-image-hexdump.png"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;We know that the image consists of 88 x 27 = 2376 pixels.  And since
the input to &lt;code&gt;bmp2logo.exe&lt;/code&gt; has to be a 1-bit-per-pixel uncompressed
image, let's just assume that the same holds true for the output.
This would give us a payload size of 297 bytes, so with a file size of
304 bytes, there should be a 11 byte header.&lt;/p&gt;
&lt;p&gt;The first two bytes were always the same, no matter what image was
converted.  So it should be fairly safe to assume that they are a
&lt;a href="https://en.wikipedia.org/wiki/Magic_number_(programming)"&gt;magic number&lt;/a&gt;.  The next two bytes were always
different for different serial numbers or different images.  Also, if
the serial number was changed by one, these two bytes would also
change in a fairly consistent manner, so this appeared to be some some
sort of checksum.  Let's just ignore that for now and put it aside for
later.&lt;/p&gt;
&lt;p&gt;The next three bytes were all zeros, followed by a byte representing
the serial number.  Or so i thought at first, until I passed a serial
number of &lt;code&gt;-1&lt;/code&gt; to &lt;code&gt;bmp2logo.exe&lt;/code&gt;, and got &lt;code&gt;ff ff ff ff&lt;/code&gt; as these four
bytes, so this pretty clearly is a 32-bit representation of this
number.  The next two bytes were pretty obvious as well, they are the
height and width of the image, 27 and 88 respectively.&lt;/p&gt;
&lt;p&gt;The last byte of the header seems to represent the number of bytes
that comprise a single row.  I'm not entirely sure about this, but it
made the most sense, especially if we assume that there are other
phones out there which may support grayscale images, and need a
greater color depth and consequently more bits per row.&lt;/p&gt;
&lt;p&gt;Now, on to the last part: The checksum.  I was a bit lost here, so I
just tried various ways of compressing data into 16 bits: Adding them,
xor'ing them, swapping bytes around before adding them, always
wrapping the results to 16 bits.  In the end it was a typo that
brought me to the solution: Instead of &lt;code&gt;sum = (sum + swapped) &amp;amp;
0xffff&lt;/code&gt;, I wrote &lt;code&gt;sum = (sum + swapped) % 0xffff&lt;/code&gt;.  So instead of
performing addition mod 65536, i was performing addition mod 65535 by
accident.  The result of this turned out to be just the binary inverse
of the intended checksum, so let's add a final &lt;code&gt;sum ^= 0xffff&lt;/code&gt; before
returning the result and call it a day.&lt;/p&gt;
&lt;h2 id="file-format"&gt;File Format&lt;/h2&gt;
&lt;p&gt;With all that information, and verifying that the payload did indeed
match the uncompressed monochrome image, we finally got our file
format:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nb"&gt;+----+----+----+----+----+----+----+----+&lt;/span&gt;
&lt;span class="c"&gt;| 10h 60h |  CHKSUM |       SERIAL      |&lt;/span&gt;
&lt;span class="nb"&gt;+----+----+----+----+----+----+----+----+&lt;/span&gt;
&lt;span class="c"&gt;| H  | W  | RW |                        |&lt;/span&gt;
&lt;span class="nb"&gt;+----+----+----+&lt;/span&gt;&lt;span class="c"&gt;                        |&lt;/span&gt;
&lt;span class="c"&gt;|                                       |&lt;/span&gt;
&lt;span class="c"&gt;:               PAYLOAD                 :&lt;/span&gt;
&lt;span class="c"&gt;:                                       :&lt;/span&gt;
&lt;span class="c"&gt;|                                       |&lt;/span&gt;
&lt;span class="c"&gt;|                                       |&lt;/span&gt;
&lt;span class="nb"&gt;+----+----+----+----+----+----+----+----+&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;To summarize, the file consists of an 11 byte header, followed by the
payload.  The header consists of:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The magic number &lt;code&gt;0x10 0x60&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;A 16 bit checksum&lt;/li&gt;
&lt;li&gt;The 32 bit serial number in big endian&lt;/li&gt;
&lt;li&gt;&lt;code&gt;H&lt;/code&gt;, the height of the image in pixels, 1 byte.  Since the image is
   27px high, this is 27, or &lt;code&gt;0x1b&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;W&lt;/code&gt;, the width of the image in pixels, 1 byte.  Since the image is
   88px wide, this is 88, or &lt;code&gt;0x58&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;RW&lt;/code&gt;, the number of bytes in a single row, 1 byte.  Since the image
   is 88px wide and each byte holds 8 pixels at once, this is 11, or
   &lt;code&gt;0x0b&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Following the header is the image payload, with each pixel expressed
as a single bit, in row-major order starting in the top left corner.&lt;/p&gt;
&lt;p&gt;A Python implementation of the checksum algorithm could  like this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nb"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="c1"&gt;# Iterate the file starting at byte 4 (directly after the checksum)&lt;/span&gt;
&lt;span class="c1"&gt;# Iterate two bytes at a time (a and b), stick them together in&lt;/span&gt;
&lt;span class="c1"&gt;# reverse order, and add to the total sum mod 0xffff.&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
    &lt;span class="nb"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mh"&gt;0xffff&lt;/span&gt;
&lt;span class="c1"&gt;# Flip all bits in the result&lt;/span&gt;
&lt;span class="nb"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;^=&lt;/span&gt; &lt;span class="mh"&gt;0xffff&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;h3 id="implementation"&gt;Implementation&lt;/h3&gt;
&lt;p&gt;Now that we know how such an image file is composed, we can write a
tool to generate these files.  And here's the result:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://s3lph.me/images/2020-07-14-cisco-7900-result.jpg"&gt;&lt;img alt="Cisco 7900 phone screen with a cat instead of the Cisco
logo" src="https://s3lph.me/images/2020-07-14-cisco-7900-result.jpg"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;You can find the converter script on &lt;a href="https://gitlab.com/snippets/1995541"&gt;Gitlab&lt;/a&gt;.  It can be used
in exactly the same way as the original &lt;code&gt;bmp2logo.exe&lt;/code&gt; converter:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;./ciscologo.py &amp;lt;serial&amp;gt; &amp;lt;infile.something&amp;gt; &amp;lt;outfile.dat&amp;gt;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Copy &lt;code&gt;&amp;lt;outfile.dat&amp;gt;&lt;/code&gt; to your TFTP server&lt;/li&gt;
&lt;li&gt;Update the phone configuration to point to the new file&lt;/li&gt;
&lt;li&gt;Recompile the configuration file and copy it to your TFTP server&lt;/li&gt;
&lt;li&gt;Reboot your phone&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Remember to increment the serial number every time you change the
image, or the phone won't pick up the new file.&lt;/p&gt;</content><category term="hacks"/><category term="Telephone"/><category term="Reverse Engineering"/><category term="File Formats"/></entry><entry><title>Scaling and Slicing PDF Documents with pdfjam and mutool</title><link href="https://s3lph.me/scaling-and-slicing-pdf-documents-with-pdfjam-and-mutool.html" rel="alternate"/><published>2020-06-22T00:00:00+02:00</published><updated>2020-06-22T00:00:00+02:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2020-06-22:/scaling-and-slicing-pdf-documents-with-pdfjam-and-mutool.html</id><summary type="html">&lt;p&gt;I recently encountered the following challenge: I had a PDF document
consisting of multiple A4 pages, each of which I needed to print
scaled up to A2.  However, I only had an A4 printer available, and the
printer driver was not able to perform the scaling and/or slicing on …&lt;/p&gt;</summary><content type="html">&lt;p&gt;I recently encountered the following challenge: I had a PDF document
consisting of multiple A4 pages, each of which I needed to print
scaled up to A2.  However, I only had an A4 printer available, and the
printer driver was not able to perform the scaling and/or slicing on
its own.&lt;/p&gt;
&lt;p&gt;After a long search, and lots of disappointments (since neither of
Libre Office, Inkscape and Evince could do what I wanted, or I simply
couldn't find the feature), I came up with the following solution:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;$&lt;span class="w"&gt; &lt;/span&gt;pdfjam&lt;span class="w"&gt; &lt;/span&gt;-o&lt;span class="w"&gt; &lt;/span&gt;scaled.pdf&lt;span class="w"&gt; &lt;/span&gt;--a2paper&lt;span class="w"&gt; &lt;/span&gt;input.pdf
$&lt;span class="w"&gt; &lt;/span&gt;mutool&lt;span class="w"&gt; &lt;/span&gt;poster&lt;span class="w"&gt; &lt;/span&gt;-x&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-y&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;scaled.pdf&lt;span class="w"&gt; &lt;/span&gt;sliced.pdf
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;The first command takes the input file, scales it up to A2 size, and
writes it to an intermediate file.  The second command slices each
page from the intermediate file into 2 slices both vertically and
horizontally, totaling in 4 slices per page, which again results in
A4-sized slices the printer can print.&lt;/p&gt;</content><category term="hacks"/><category term="PDF"/><category term="pdfjam"/><category term="mutool"/></entry><entry><title>Automated Debian package building with Gitlab CI and Reprepro</title><link href="https://s3lph.me/automated-debian-package-building-with-gitlab-ci-and-reprepro.html" rel="alternate"/><published>2020-06-21T00:00:00+02:00</published><updated>2020-06-21T00:00:00+02:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2020-06-21:/automated-debian-package-building-with-gitlab-ci-and-reprepro.html</id><summary type="html">&lt;p&gt;I previously deployed some ad-hoc services using ugly and difficult to
maintain solutions, such as binaries manually extracted from container
images and copied into &lt;code&gt;/usr/local/bin&lt;/code&gt;.  Of course, this is a lot of
manual work for each upgrade of the service, and if you upgrade often,
a lot of …&lt;/p&gt;</summary><content type="html">&lt;p&gt;I previously deployed some ad-hoc services using ugly and difficult to
maintain solutions, such as binaries manually extracted from container
images and copied into &lt;code&gt;/usr/local/bin&lt;/code&gt;.  Of course, this is a lot of
manual work for each upgrade of the service, and if you upgrade often,
a lot of repetitive manual work.  Or, in other words, the perfect kind
of work to be automated.&lt;/p&gt;
&lt;p&gt;So I decided to package the software in question for Debian, which is
the Linux distribution I run these services on, and to automate the
process of building the packages and publishing them to a
repository. There's three different "categories" of software I wanted
to have in my repository:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Binaries published as build artifacts in releases of their upstream
  repository.  An example for this is &lt;a href="https://github.com/gitea/go-gitea"&gt;Gitea&lt;/a&gt;. The binaries can
  be automatically downloaded and put into a package.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Software released as container images only. An example for this is
  the &lt;a href="https://hub.docker.com/r/drone/drone"&gt;Drone CI Server&lt;/a&gt;.  This is a bit more tricky, as the
  binaries must be extracted from the image before it can be put into
  a package.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Software already released as Debian packages, but not available
  through a repository.  An example for this is a project of my own,
  the &lt;a href="https://gitlab.com/s3lph/icalendar-timeseries-server"&gt;iCalendar Timeseries Server&lt;/a&gt;, where the CI pipeline
  automatically builds Debian packages and adds them as build
  artifacts to releases.  Here, the already-built package just needs
  to be fetched and added to the repository.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For creating and maintaining the repository, I'm using
&lt;a href="https://salsa.debian.org/brlink/reprepro"&gt;Reprepro&lt;/a&gt;, as it is extremely simple to use, and generally
is rather uncomplex and lightweight.  It does however come with some
limitations; I especially encountered the problem that Reprepro will
only keep the latest version of a package in the repo index, so older
versions won't be available to clients.&lt;/p&gt;
&lt;p&gt;For automating the packaging and repository build process, I'm using
&lt;a href="https://docs.gitlab.com/ee/ci/"&gt;Gitlab CI&lt;/a&gt;. The pipeline is running each night, building the
latest stable version for each package, adding the packages to the
in-container Reprepro repository, and then synchronize the repository
to a web server of mine.&lt;/p&gt;
&lt;p&gt;The source repository with the package build scripts and pipeline
configuration is available on &lt;a href="https://gitlab.com/s3lph/custom-packages"&gt;Gitlab&lt;/a&gt;.  Though,
before using this as a template, be advised that the packages built by
this pipeline are not exceptionally high-quality.  They are also
usually built to fit my personal needs, so don't expect ready-to-use
packages.&lt;/p&gt;</content><category term="hacks"/><category term="Software Packaging"/><category term="Continuous Delivery"/><category term="Reprepro"/></entry><entry><title>Recording IPTV Using ffmpeg</title><link href="https://s3lph.me/recording-iptv-using-ffmpeg.html" rel="alternate"/><published>2020-05-20T00:00:00+02:00</published><updated>2020-05-20T00:00:00+02:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2020-05-20:/recording-iptv-using-ffmpeg.html</id><summary type="html">&lt;p&gt;I don't usually watch TV.  But from time to time there is something
interesting on the programme, such as debates on local politics.
Unfortunately, those usually run at a time of day where I'm not able
(or more likely not willing) to tune in an pay attention to an hour …&lt;/p&gt;</summary><content type="html">&lt;p&gt;I don't usually watch TV.  But from time to time there is something
interesting on the programme, such as debates on local politics.
Unfortunately, those usually run at a time of day where I'm not able
(or more likely not willing) to tune in an pay attention to an hour of
political discourse.  So I want to record them and watch later
instead.&lt;/p&gt;
&lt;p&gt;My ISP provides its IPTV programme as MPEG-TS streams via multicast
UDP.  And they even link a M3U playlist of all stations on their
website, so you can basically watch TV with any client whatsover, as
long as it speaks IGMP and understands MPEG-TS video streams.  This
makes recording very easy, as this is supported by a lot of multimedia
processing software, including ffmpeg.&lt;/p&gt;
&lt;p&gt;The playlist consists of a list of TV stations, each of which is
represented by its own multicast group and an UDP port.  So let's just
take the first station and see what ffmpeg finds in there:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;Input #0, mpegts, from &amp;#39;udp://239.77.0.77:5000&amp;#39;:
  Duration: N/A, start: 41892.675600, bitrate: N/A
  Program 9038 
    Metadata:
      service_name    : SRF 1 HD
      service_provider: Schweizer Radio und Fernsehen
    Stream #0:0[0x50]: Video: h264 (High) ([27][0][0][0] / 0x001B), yuv420p(tv, bt709, progressive), 1280x720 [SAR 1:1 DAR 16:9], 50 fps, 50 tbr, 90k tbn, 100 tbc
    Stream #0:1[0x51](deu): Audio: mp2 ([3][0][0][0] / 0x0003), 48000 Hz, stereo, fltp, 192 kb/s (clean effects)
    Stream #0:2[0x52](eng): Audio: mp2 ([3][0][0][0] / 0x0003), 48000 Hz, stereo, fltp, 192 kb/s (clean effects)
    Stream #0:3[0x5b](deu): Audio: ac3 ([6][0][0][0] / 0x0006), 48000 Hz, 5.1(side), fltp, 448 kb/s (clean effects)
    Stream #0:4[0x6e](deu,deu): Subtitle: dvb_teletext ([6][0][0][0] / 0x0006)
    Stream #0:5[0x70]: Unknown: none ([5][0][0][0] / 0x0005)
    Stream #0:6[0x72]: Unknown: none ([12][0][0][0] / 0x000C)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;We can see that the MPEG-TS stream contains multiple indiviual
streams, which are listed in the output above.  Now, I don't know
what's up with &lt;code&gt;0:5&lt;/code&gt; and &lt;code&gt;0:6&lt;/code&gt;, or why ffmpeg doesn't understand
them. Anyway, I only need the video and one audio channel.  Let's just
pick the first two, and record an one hour TV show:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;ffmpeg&lt;span class="w"&gt; &lt;/span&gt;-f&lt;span class="w"&gt; &lt;/span&gt;mpegts&lt;span class="w"&gt; &lt;/span&gt;-i&lt;span class="w"&gt; &lt;/span&gt;udp://239.77.0.77:5000&lt;span class="w"&gt; &lt;/span&gt;-map&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;:0&lt;span class="w"&gt; &lt;/span&gt;-map&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;:1&lt;span class="w"&gt; &lt;/span&gt;-c&lt;span class="w"&gt; &lt;/span&gt;copy&lt;span class="w"&gt; &lt;/span&gt;-t&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;3600&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;recording.mkv
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;To break it down:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;-f mpegts&lt;/code&gt; tells ffmpeg that the input is an MPEG transport stream.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-i udp://239.77.0.77:5000&lt;/code&gt; tells ffmpeg to join the specified multicast group and receive the MPEG-TS stream on UDP port 5000.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-map 0:0 -map 0:1&lt;/code&gt; only extracts the streams &lt;code&gt;0:0&lt;/code&gt; (the H.264 video stream) and &lt;code&gt;0:1&lt;/code&gt; (the German audio channel in MP2)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-c copy&lt;/code&gt; causes the input stream to be demuxed only, and the selected streams to be written to the output without CPU-intensive decoding and reencoding.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-t 3600&lt;/code&gt; terminates the stream after one hour, when the show is over.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;recording.mkv&lt;/code&gt; is the output filename.  The container format (here MKV) is deduced from the filename.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So the whole ffmpeg command takes the original stream as input,
demultiplexes it to get the individual media streams, then throws out
all but one video and one audio stream, multiplexes them into a
Matroska container, which is then written to disk.&lt;/p&gt;</content><category term="hacks"/><category term="IPTV"/><category term="ffmpeg"/><category term="TV"/></entry><entry><title>Extracting 3D Models From CesiumJS - Part 1: Terrain Map Scraping</title><link href="https://s3lph.me/extracting-3dmodels-from-cesium-part-1-terrain-map-scraping.html" rel="alternate"/><published>2020-05-09T00:00:00+02:00</published><updated>2020-05-09T00:00:00+02:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2020-05-09:/extracting-3dmodels-from-cesium-part-1-terrain-map-scraping.html</id><summary type="html">&lt;p&gt;&lt;a href="https://github.com/CesiumGS/cesium"&gt;CesiumJS&lt;/a&gt; is a open source JavaScript framework for
rendering 2D and 3D maps - everything from a local area to whole
planets - in a web browser using WebGL.  In the past few weeks I've
been working on obtaining 3D model data in a situation where the only
easily available way of …&lt;/p&gt;</summary><content type="html">&lt;p&gt;&lt;a href="https://github.com/CesiumGS/cesium"&gt;CesiumJS&lt;/a&gt; is a open source JavaScript framework for
rendering 2D and 3D maps - everything from a local area to whole
planets - in a web browser using WebGL.  In the past few weeks I've
been working on obtaining 3D model data in a situation where the only
easily available way of accessing the data is through a CesiumJS based
viewer.  As far as I know, Cesium deals with two different kinds of 3D
data: On one side, there's 3D models used for small-scale objects like
buildings, on the other side there's terrain maps.&lt;/p&gt;
&lt;h2 id="addressing-terrain-tiles"&gt;Addressing Terrain Tiles&lt;/h2&gt;
&lt;p&gt;To get started with the terrain map, I needed to figure out how to
obtain terrain data for a certain geographical region.  Luckily, this
is fairly well documented.  CesiumJS uses its own solution called
&lt;a href="https://github.com/CesiumGS/quantized-mesh"&gt;quantized-mesh&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;quantized-mesh supports different "zoom levels", for which the whole
globe is divided into more and more single "tiles".  At level 0, there
are only two tiles: the first tile covers the western hemisphere, the
second tile covers the eastern hemisphere.  With each increase in zoom
level, each tile is split into 4, each new tile containing a quadrant
of the previous tile.  Each tile can then be identified by its zoom
level, a &lt;code&gt;x&lt;/code&gt; coordinate and a &lt;code&gt;y&lt;/code&gt; coordinate. &lt;code&gt;x&lt;/code&gt; starts at 0,
representing &lt;code&gt;-180°&lt;/code&gt; longitude, and when incrementing &lt;code&gt;x&lt;/code&gt;, you go
eastward until you reach the tile ending at &lt;code&gt;+180°&lt;/code&gt; longitude, at
&lt;code&gt;x=2^(z+1)&lt;/code&gt;. &lt;code&gt;y=0&lt;/code&gt; starts at the south pole at &lt;code&gt;-90°&lt;/code&gt; latitude,
going north, and reaches &lt;code&gt;+90°&lt;/code&gt; latitude at &lt;code&gt;y=2^z&lt;/code&gt;.  No &lt;em&gt;+1&lt;/em&gt;
in the exponent here, since for full coverage, &lt;code&gt;x&lt;/code&gt; needs to cover the
full 360° longitude, while &lt;code&gt;y&lt;/code&gt; only needs to cover half as much for a
total of 180° latitude.&lt;/p&gt;
&lt;p&gt;Using these three variables, &lt;abbr title="Yes, in that order. Yes,
it's weird at first, but z stands for zoom, not a z coordinate."&gt;&lt;code&gt;z&lt;/code&gt;,
&lt;code&gt;x&lt;/code&gt; and &lt;code&gt;y&lt;/code&gt;&lt;/abbr&gt;, the quantized-mesh specification defines an URL
template for addressing an individual tile via HTTP:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;http://example.com/tiles/&amp;lt;z&amp;gt;/&amp;lt;x&amp;gt;/&amp;lt;y&amp;gt;.terrain
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;This can be a little hard to imagine, so I attempted to visualize the
first two zoom levels in &lt;a href="#figure1"&gt;Figure 1&lt;/a&gt;.&lt;/p&gt;
&lt;figure&gt;
  &lt;a href="https://s3lph.me/images/2020-05-09-cesium-quantized-mesh-level0.png"&gt;
    &lt;img src="https://s3lph.me/images/2020-05-09-cesium-quantized-mesh-level0.png"
         title="Zoom level 0: 2 tiles, each a hemisphere"
         alt="Zoom level 0: 2 tiles, each a hemisphere"&gt;
  &lt;/a&gt;
  &lt;a href="https://s3lph.me/images/2020-05-09-cesium-quantized-mesh-level1.png"&gt;
    &lt;img src="https://s3lph.me/images/2020-05-09-cesium-quantized-mesh-level1.png"
         title="Zoom level 1: 8 tiles, each an octant"
         alt="Zoom level 1: 8 tiles, each an octant"&gt;
  &lt;/a&gt;
  &lt;figcaption&gt;
    &lt;a id="figure1" class="figureid"&gt;Figure 1:&lt;/a&gt; Tiles at zoom level 0 and 1:
    At level 0, there are 2 tiles, each tile covering a hemisphere.
    At level 1, there are 8 tiles in total. Each tile from level 0
    was divided into 4 tiles
  &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;If you're familiar with OpenStreetMap, you may recognize this way of
dividing the globe into tiles and addressing individual tiles. An
OpenStreetMap tile URL looks like this:
&lt;a href="https://tile.openstreetmap.org/14/8537/5725.png"&gt;https://tile.openstreetmap.org/14/8537/5725.png&lt;/a&gt;.  This
similarity is not accidental; in fact, the quantized-mesh tiling
schema was designed to follow the &lt;a href="https://wiki.osgeo.org/wiki/Tile_Map_Service_Specification"&gt;Tile Map Service&lt;/a&gt; standard's
tiling schema.&lt;/p&gt;
&lt;p&gt;All of the above assumes our data source uses a WGS84 projection and
the TMS tiling schema.  quantized-mesh supports other configurations
as well, where there is only a single tile at level 0, or with the &lt;code&gt;x&lt;/code&gt;
and &lt;code&gt;y&lt;/code&gt; coordinates swapped.  You can find more information in the
documentation.&lt;/p&gt;
&lt;h2 id="mapping-geographical-regions-to-terrain-tiles"&gt;Mapping Geographical Regions to Terrain Tiles&lt;/h2&gt;
&lt;p&gt;Now that we know how quantized-mesh tiles are addressed, let's find
out which tiles we actually need.  In my use case, I wanted to obtain
all tiles in a bounding box defined by lower and upper latitudes and
longitudes.  Converting &lt;code&gt;x&lt;/code&gt; and &lt;code&gt;y&lt;/code&gt; to coordinates is quite easy:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="n"&gt;lat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;lon&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;180&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;So to get the ranges for x and y, we solve those equations for &lt;code&gt;x&lt;/code&gt; and
&lt;code&gt;y&lt;/code&gt; and add proper rounding:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="n"&gt;x_min&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="n"&gt;lon_min&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;180&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt;     &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;x_max&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;  &lt;span class="n"&gt;lon_max&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;180&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt;     &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;y_min&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="n"&gt;lat_min&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;180&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;y_max&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;  &lt;span class="n"&gt;lat_max&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;180&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;The resulting ranges, with &lt;code&gt;x_max&lt;/code&gt; and &lt;code&gt;y_max&lt;/code&gt; being exclusive upper
bounds, are then formulated as&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;tiles_x = range(x_min, x_max)
tiles_y = range(y_min, y_max)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;So once we know at which zoom level our tiles are available, we can
compute which tiles to download in order to fully cover our region.&lt;/p&gt;
&lt;h2 id="scraping-terrain-tiles"&gt;Scraping Terrain Tiles&lt;/h2&gt;
&lt;p&gt;Now all that's left to to is to figure out how to actually download
the individual tiles.  Your web browser's inspector can be a great
help here.  Open the Cesium-based application in your web browser, and
open the inspector's network tab.  As you move around in the map, you
will most likely see a lot of requests, which can be grouped into the
following categories:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Image tiles, addressed in the same way as the terrain tiles.  I
  didn't need these for my use case, but you should be able to scrape
  them the same way I'm scraping the terrain tiles.&lt;/li&gt;
&lt;li&gt;3D models, with file extensions of &lt;code&gt;b3dm&lt;/code&gt;, &lt;code&gt;glb&lt;/code&gt; and &lt;code&gt;cmpt&lt;/code&gt;.  I'll
  take a look at some of those in later articles.&lt;/li&gt;
&lt;li&gt;The terrain tiles, with a file extension of &lt;code&gt;terrain&lt;/code&gt;.  These are
  the ones we want to obtain.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;As you zoom in and out of the map, you'll see that the zoom levels in
the requests to image and terrain tiles will increase or decrease.
Since I wanted the most detailed tiles, I just continued with the
maximal zoom level that was available in the application i was working
with.&lt;/p&gt;
&lt;p&gt;Now just right click on one of the terrain requests, and navigate to
&lt;code&gt;Copy / Copy as cURL&lt;/code&gt;.  You'll get something like this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;curl&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;https://maps.example.com/terrain/tiles/12/12345/12345.terrain&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;-H&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;Origin: https://maps.example.com&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;-H&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;Accept-Encoding: gzip, deflate, br&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;-H&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;User-Agent: Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.1; ...)&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;-H&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;Accept: application/vnd.quantized-mesh,application/octet-stream;q=0.9&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;-H&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;Referer: https://maps.example.com/&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--compressed
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;At least the &lt;code&gt;Accept&lt;/code&gt; header is usually required, and the
quantized-mesh specification recommends setting it, especially since
it's used to tell the server which extensions to the quantized-mesh
standard are supported by the client, if any.  Some other headers,
especially the &lt;code&gt;Origin&lt;/code&gt;, &lt;code&gt;Referer&lt;/code&gt; and &lt;code&gt;User-Agent&lt;/code&gt; may be required as
a "soft form of access control", depending on the server's
configuration.  I found it worked best to just keep the entire curl
request as-is, and only modify the &lt;code&gt;z&lt;/code&gt;, &lt;code&gt;x&lt;/code&gt; and &lt;code&gt;y&lt;/code&gt; parameters in the
URL.&lt;/p&gt;
&lt;p&gt;Knowing the supported values for &lt;code&gt;z&lt;/code&gt; and our ranges for &lt;code&gt;x&lt;/code&gt; and &lt;code&gt;y&lt;/code&gt;,
we can now easily script the download of the individual files.  A
small hint regarding the filenames: Use all three parameters, &lt;code&gt;z&lt;/code&gt;, &lt;code&gt;x&lt;/code&gt;
and &lt;code&gt;y&lt;/code&gt; in the output filename, otherwise you end up overwriting the
same files over and over again.&lt;/p&gt;
&lt;p&gt;Once the download is done, we are left with a bunch of binary
&lt;code&gt;.terrain&lt;/code&gt; files.  The next article will cover how to parse them.&lt;/p&gt;</content><category term="hacks"/><category term="CesiumJS"/><category term="3D Models"/><category term="Web Scraping"/></entry><entry><title>Mounting a 2.5" Drive Inside an APU2</title><link href="https://s3lph.me/apu2-ssd-mount.html" rel="alternate"/><published>2020-03-07T15:15:00+01:00</published><updated>2020-03-07T15:15:00+01:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2020-03-07:/apu2-ssd-mount.html</id><summary type="html">&lt;p&gt;At home, I'm using a PC Engines APU2 as a firewall and WiFi access
point.  Since this is the only device in my home constantly running
and constantly connected to the internet, I decided to use it as an
additional backup site for my servers.&lt;/p&gt;
&lt;p&gt;However, for this to become …&lt;/p&gt;</summary><content type="html">&lt;p&gt;At home, I'm using a PC Engines APU2 as a firewall and WiFi access
point.  Since this is the only device in my home constantly running
and constantly connected to the internet, I decided to use it as an
additional backup site for my servers.&lt;/p&gt;
&lt;p&gt;However, for this to become viable, I needed to add around 200 GB of
storage.  I considered the following options:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Add more mSATA storage&lt;/li&gt;
&lt;li&gt;Attach external (USB?) storage&lt;/li&gt;
&lt;li&gt;Attach a 2.5" drive internally&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I quickly discarded the second option, since this approach would take
up more space, which could become challenging especially since I
wall-mounted the device.  As for the other two options, I had a few
spare 2.5" drives lying around, so I figured I'd try to use those
first before buying new storage.&lt;/p&gt;
&lt;h2 id="the-problem"&gt;The Problem&lt;/h2&gt;
&lt;p&gt;Space inside the APU2 case is tight.  There is some 9-10mm space
between the highest parts of the system board (pin headers,
capacitors, mPCIe and mSATA cards) and the case cover, at least for
most parts of the board's footprint.  Sure, I could just try to glue
the drive to the case ceiling, but that would be both ugly and
extremely cumbersome to handle.  Also, I really wanted to prevent the
disk from ever touching anything on the board, since most disk casings
are made from metal, and the things it could touch on the board are
stuffs like pin headers.&lt;/p&gt;
&lt;h2 id="the-solution"&gt;The Solution&lt;/h2&gt;
&lt;p&gt;So I decided to come up with a 3D-printed mount.  It consists of 3
parts: two side parts, which are put between the board and the case
walls, and a center part, on which the disk is screwed in place, held
in place by the two side parts.  Effectively, a "bridge" over the
system board.&lt;/p&gt;
&lt;p&gt;The first attempt already turned out pretty well, but a few problems
became apparent:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;I took a wrong measurement at one point and had to move some cutouts
  so they would properly align with the pin headers they were meant
  for.&lt;/li&gt;
&lt;li&gt;The plugs of SATA cables (at least the ones I had lying around, as
  well as the PC Engines satacab1) extended below the "base line" of
  the disk they are plugged into.  This was solved by adding another
  hole into the 3D-printed part.&lt;/li&gt;
&lt;li&gt;Due to the limited space, this design only works for drives 7mm
  high.  The much more common 9.5mm high drives won't fit, or at least
  put some stress on the printed part, and possible the system board.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So I ended up using a 250GB SSD (the only 7mm drive I had lying
around).  However, when the PC Engines-specific SATA cable ("satacab1",
required because there is no SATA power connector on the APU2 board)
arrived, another problem popped up:&lt;/p&gt;
&lt;p&gt;The cable is both quite short and rather rigid, so if you use it to
connect the drive to the SATA connector on the APU2 board, you end up
putting some stress on both the connector and the printed parts.  This
can be easily mitigated by putting a short SATA extension cable in
between the satacab1 and the board's connector.&lt;/p&gt;
&lt;h2 id="the-result"&gt;The Result&lt;/h2&gt;
&lt;p&gt;I've published the resulting design on &lt;a href="https://www.thingiverse.com/thing:4207011"&gt;Thingiverse&lt;/a&gt;.
Alternatively, you can download the files &lt;a href="https://s3lph.me/files/2020-03-07-apu2-ssd-mount.tar.xz"&gt;directly from here&lt;/a&gt;.
The design is licensed under the &lt;a href="https://creativecommons.org/licenses/by-sa/4.0/"&gt;CC BY-SA 4.0 license&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The results of my print can be seen in the following photos.  I made
this print out of PLA using an Ultimaker 3.&lt;/p&gt;
&lt;p&gt;&lt;span class="photostream"&gt;
&lt;a href="https://s3lph.me/images/2020-03-07-apu2_ssd_mount_without_ssd.jpg"&gt;&lt;img alt="APU2 in its case with the mount on top, but without a disk
attached" src="https://s3lph.me/thumb/2020-03-07-apu2_ssd_mount_without_ssd.jpg"&gt;&lt;/a&gt;
&lt;a href="https://s3lph.me/images/2020-03-07-apu2_ssd_mount_with_ssd.jpg"&gt;&lt;img alt="APU2 in its case with a mounted SSD on
top" src="https://s3lph.me/thumb/2020-03-07-apu2_ssd_mount_with_ssd.jpg"&gt;&lt;/a&gt;
&lt;/span&gt;&lt;/p&gt;</content><category term="hacks"/><category term="3D Printing"/><category term="3D Models"/><category term="APU2"/><category term="Hardware"/><category term="Hard Disk"/><category term="SSD"/></entry><entry><title>Binary Calculator Implementation in Apache mod_rewrite</title><link href="https://s3lph.me/binary-calculator-implementation-in-apache-mod_rewrite.html" rel="alternate"/><published>2020-02-13T02:00:00+01:00</published><updated>2020-02-13T02:00:00+01:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2020-02-13:/binary-calculator-implementation-in-apache-mod_rewrite.html</id><summary type="html">&lt;p&gt;Many text replacement dialects utilizing regular expressions become
&lt;a href="https://en.wikipedia.org/wiki/Turing_completeness"&gt;turing complete&lt;/a&gt; when combined with conditional loops or
recursion. Apache's &lt;a href="https://httpd.apache.org/docs/current/mod/mod_rewrite.html"&gt;mod_rewrite&lt;/a&gt; is no exception here; you
can write turing-complete programs using &lt;code&gt;RewriteRule&lt;/code&gt;s only.  A few
weeks ago I decided to try my hand at this and wrote a binary
calculator (well …&lt;/p&gt;</summary><content type="html">&lt;p&gt;Many text replacement dialects utilizing regular expressions become
&lt;a href="https://en.wikipedia.org/wiki/Turing_completeness"&gt;turing complete&lt;/a&gt; when combined with conditional loops or
recursion. Apache's &lt;a href="https://httpd.apache.org/docs/current/mod/mod_rewrite.html"&gt;mod_rewrite&lt;/a&gt; is no exception here; you
can write turing-complete programs using &lt;code&gt;RewriteRule&lt;/code&gt;s only.  A few
weeks ago I decided to try my hand at this and wrote a binary
calculator (well, only an adder, to be precise) implemented in an
Apache2 config file.&lt;/p&gt;
&lt;h2 id="mod_rewrite-basics"&gt;mod_rewrite Basics&lt;/h2&gt;
&lt;p&gt;Apache's mod_rewrite allows for conditionally rewriting request URLs.
A &lt;code&gt;RewriteRule&lt;/code&gt; directive looks like this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nb"&gt;RewriteRule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;pattern&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;replacement&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;[flags]
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;The pattern is matched against the URL of the request, and if it
matches, the matching region is replaced by the replacement
expression.  The &lt;a href="https://httpd.apache.org/docs/current/rewrite/flags.html"&gt;flags&lt;/a&gt; control the exact behavior of the
rule.  We only make use of three flags in this implementation:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;L&lt;/code&gt; aka &lt;code&gt;last&lt;/code&gt; terminates the rewrite engine, no more rules are
  processed for this request.  &lt;code&gt;L&lt;/code&gt; is used to "exit the program".&lt;/li&gt;
&lt;li&gt;&lt;code&gt;R&lt;/code&gt; aka &lt;code&gt;redirect&lt;/code&gt; causes the web server to respond with a &lt;code&gt;302
  Found&lt;/code&gt;, rather than processing the rewritten URL internally.  This
  is used to return the result to the client in the &lt;code&gt;Location&lt;/code&gt; header.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;N&lt;/code&gt; aka &lt;code&gt;next&lt;/code&gt; causes the rewrite engine to start over from the
  beginning, operating on the result of the last iteration.  The
  combination of &lt;code&gt;N&lt;/code&gt; and &lt;code&gt;L&lt;/code&gt; is what gives us conditional recursion.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="the-calculator"&gt;The Calculator&lt;/h2&gt;
&lt;p&gt;The calculator works as explained in the following example, which adds
the binary representations of 11 and 7, and should ideally result in
18.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The client sends a request such as &lt;code&gt;GET /1011+111 HTTP/1.1&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;The server first adds an equality sign to the end:  &lt;code&gt;/1011+111=&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The server iteratively performs the addition bit-by-bit (&lt;code&gt;#&lt;/code&gt; represents the carry bit):&lt;ol&gt;
&lt;li&gt;&lt;code&gt;/1011+111=&lt;/code&gt; &amp;rarr; &lt;code&gt;/101+11=#0&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/101+11=#0&lt;/code&gt; &amp;rarr; &lt;code&gt;/10+1=#10&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/10+1=#10&lt;/code&gt; &amp;rarr; &lt;code&gt;/1+=#010&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/1+=#010&lt;/code&gt; &amp;rarr; &lt;code&gt;/1+0=#010&lt;/code&gt; (add zero padding)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/1+0=#010&lt;/code&gt; &amp;rarr; &lt;code&gt;/+=#0010&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;The server cleans up the request, removing the operators and resolving the last carry bit, if present:&lt;ul&gt;
&lt;li&gt;&lt;code&gt;/+=#0010&lt;/code&gt; &amp;rarr; &lt;code&gt;/10010&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;The server responds with a redirect with a &lt;code&gt;Location: /10010&lt;/code&gt; header.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;And here's the whole mod_rewrite Config:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nb"&gt;RewriteEngine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;on&lt;/span&gt;
&lt;span class="c"&gt;# Termination condition: no more digits left, also strips leading zeros&lt;/span&gt;
&lt;span class="nb"&gt;RewriteRule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;^/\+=0*([01]+)$&amp;quot;&lt;/span&gt;&lt;span class="w"&gt;                    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;/$1&amp;quot;&lt;/span&gt;&lt;span class="w"&gt;           &lt;/span&gt;[L,R]
&lt;span class="c"&gt;# Termination condition with carry flag&lt;/span&gt;
&lt;span class="nb"&gt;RewriteRule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;^/\+=#0*([01]+)&amp;quot;&lt;/span&gt;&lt;span class="w"&gt;                    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;/1$1&amp;quot;&lt;/span&gt;&lt;span class="w"&gt;          &lt;/span&gt;[L,R]
&lt;span class="c"&gt;# Add = if absent&lt;/span&gt;
&lt;span class="nb"&gt;RewriteRule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;^/([01]+)\+([01]+)$&amp;quot;&lt;/span&gt;&lt;span class="w"&gt;              &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;/$1+$2=&amp;quot;&lt;/span&gt;&lt;span class="w"&gt;       &lt;/span&gt;
&lt;span class="c"&gt;# Pad first number with zeros if too short&lt;/span&gt;
&lt;span class="nb"&gt;RewriteRule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;^/\+([01]+)=(#?[01]*)&amp;quot;&lt;/span&gt;&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;/0+$1=$2&amp;quot;&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;
&lt;span class="c"&gt;# Pad second number with zeros if too short&lt;/span&gt;
&lt;span class="nb"&gt;RewriteRule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;^/([01]+)\+=(#?[01]*)&amp;quot;&lt;/span&gt;&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;/$1+0=$2&amp;quot;&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;
&lt;span class="c"&gt;# 0+0 nocarry&lt;/span&gt;
&lt;span class="nb"&gt;RewriteRule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;^/([01]*)0\+([01]*)0=([01]*)$&amp;quot;&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;/$1+$2=0$3&amp;quot;&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;[N]
&lt;span class="c"&gt;# 0+0 carry&lt;/span&gt;
&lt;span class="nb"&gt;RewriteRule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;^/([01]*)0\+([01]*)0=#([01]*)$&amp;quot;&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;/$1+$2=1$3&amp;quot;&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;[N]
&lt;span class="c"&gt;# 1+0 nocarry&lt;/span&gt;
&lt;span class="nb"&gt;RewriteRule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;^/([01]*)1\+([01]*)0=([01]*)$&amp;quot;&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;/$1+$2=1$3&amp;quot;&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;[N]
&lt;span class="c"&gt;# 1+0 carry&lt;/span&gt;
&lt;span class="nb"&gt;RewriteRule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;^/([01]*)1\+([01]*)0=#([01]*)$&amp;quot;&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;/$1+$2=#0$3&amp;quot;&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;[N]
&lt;span class="c"&gt;# 0+1 nocarry&lt;/span&gt;
&lt;span class="nb"&gt;RewriteRule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;^/([01]*)0\+([01]*)1=([01]*)$&amp;quot;&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;/$1+$2=1$3&amp;quot;&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;[N]
&lt;span class="c"&gt;# 0+1 carry&lt;/span&gt;
&lt;span class="nb"&gt;RewriteRule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;^/([01]*)0\+([01]*)1=#([01]*)$&amp;quot;&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;/$1+$2=#0$3&amp;quot;&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;[N]
&lt;span class="c"&gt;# 1+1 nocarry&lt;/span&gt;
&lt;span class="nb"&gt;RewriteRule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;^/([01]*)1\+([01]*)1=([01]*)$&amp;quot;&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;/$1+$2=#0$3&amp;quot;&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;[N]
&lt;span class="c"&gt;# 1+1 carry&lt;/span&gt;
&lt;span class="nb"&gt;RewriteRule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;^/([01]*)1\+([01]*)1=#([01]*)$&amp;quot;&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;/$1+$2=#1$3&amp;quot;&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;[N]
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;</content><category term="hacks"/><category term="Apache 2"/><category term="mod_rewrite"/><category term="Turing completeness"/><category term="Calculator"/><category term="Regular Expressions"/></entry><entry><title>Showing Webcal Calendar Events in Grafana</title><link href="https://s3lph.me/showing-webcal-calendar-events-in-grafana.html" rel="alternate"/><published>2020-02-12T02:00:00+01:00</published><updated>2020-02-12T02:00:00+01:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2020-02-12:/showing-webcal-calendar-events-in-grafana.html</id><summary type="html">&lt;p&gt;I'm running a Grafana at home, with a dashboard giving me an overview
over my day.  It contains information like &lt;a href="https://s3lph.me/bringing-swiss-public-transport-departures-to-grafana.html"&gt;public transport
departures&lt;/a&gt; or the &lt;a href="https://s3lph.me/automatically-rotating-guest-wifi-passwords-with-hostapd.html"&gt;guest WiFi's password&lt;/a&gt;.  But the most
important part is a list of my upcoming appointments.  Now, iCalendar
files served via HTTP is not something Grafana …&lt;/p&gt;</summary><content type="html">&lt;p&gt;I'm running a Grafana at home, with a dashboard giving me an overview
over my day.  It contains information like &lt;a href="https://s3lph.me/bringing-swiss-public-transport-departures-to-grafana.html"&gt;public transport
departures&lt;/a&gt; or the &lt;a href="https://s3lph.me/automatically-rotating-guest-wifi-passwords-with-hostapd.html"&gt;guest WiFi's password&lt;/a&gt;.  But the most
important part is a list of my upcoming appointments.  Now, iCalendar
files served via HTTP is not something Grafana understands out of the
box. To work around this, I wrote a small service that scrapes the
calendar endpoints and exposes the events as metrics in a
Prometheus-compatible API.&lt;/p&gt;
&lt;h2 id="how-it-works"&gt;How it works&lt;/h2&gt;
&lt;p&gt;Consider the following iCalendar file, served at an HTTP endpoint:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nv"&gt;BEGIN&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;VCALENDAR&lt;/span&gt;
&lt;span class="nv"&gt;VERSION&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;2.0&lt;/span&gt;
&lt;span class="nv"&gt;PRODID&lt;/span&gt;&lt;span class="o"&gt;:-//&lt;/span&gt;&lt;span class="nv"&gt;ACME&lt;/span&gt;&lt;span class="o"&gt;//&lt;/span&gt;&lt;span class="nv"&gt;NONSGML&lt;/span&gt; &lt;span class="nv"&gt;Rocket&lt;/span&gt; &lt;span class="nv"&gt;Powered&lt;/span&gt; &lt;span class="nv"&gt;Anvil&lt;/span&gt;&lt;span class="o"&gt;//&lt;/span&gt;&lt;span class="nv"&gt;EN&lt;/span&gt;
&lt;span class="nv"&gt;BEGIN&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;VEVENT&lt;/span&gt;
&lt;span class="nv"&gt;UID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;20190603&lt;/span&gt;&lt;span class="nv"&gt;T032500CEST&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="s s-Atom"&gt;foo&lt;/span&gt;
&lt;span class="nv"&gt;SUMMARY&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;Foo&lt;/span&gt;
&lt;span class="nv"&gt;DESCRIPTION&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;An&lt;/span&gt; &lt;span class="s s-Atom"&gt;example&lt;/span&gt; &lt;span class="s s-Atom"&gt;event&lt;/span&gt;
&lt;span class="nv"&gt;DTSTART&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="nv"&gt;TZID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;Europe&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;Zurich&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="nv"&gt;VALUE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;DATE&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nv"&gt;TIME&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;20190603&lt;/span&gt;&lt;span class="nv"&gt;T032500&lt;/span&gt;
&lt;span class="nv"&gt;DTEND&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="nv"&gt;TZID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;Europe&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;Zurich&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="nv"&gt;VALUE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;DATE&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nv"&gt;TIME&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;20190603&lt;/span&gt;&lt;span class="nv"&gt;T040000&lt;/span&gt;
&lt;span class="nv"&gt;END&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;VEVENT&lt;/span&gt;
&lt;span class="nv"&gt;BEGIN&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;VEVENT&lt;/span&gt;
&lt;span class="nv"&gt;UID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;20190603&lt;/span&gt;&lt;span class="nv"&gt;T032500CEST&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="s s-Atom"&gt;bar&lt;/span&gt;
&lt;span class="nv"&gt;SUMMARY&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;Bar&lt;/span&gt;
&lt;span class="nv"&gt;DESCRIPTION&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;Another&lt;/span&gt; &lt;span class="s s-Atom"&gt;example&lt;/span&gt; &lt;span class="s s-Atom"&gt;event&lt;/span&gt;
&lt;span class="nv"&gt;DTSTART&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="nv"&gt;TZID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;Europe&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;Zurich&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="nv"&gt;VALUE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;DATE&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nv"&gt;TIME&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;20190603&lt;/span&gt;&lt;span class="nv"&gt;T032500&lt;/span&gt;
&lt;span class="nv"&gt;DTEND&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="nv"&gt;TZID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;Europe&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;Zurich&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="nv"&gt;VALUE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;DATE&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nv"&gt;TIME&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;20190603&lt;/span&gt;&lt;span class="nv"&gt;T040000&lt;/span&gt;
&lt;span class="nv"&gt;END&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;VEVENT&lt;/span&gt;
&lt;span class="nv"&gt;END&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;VCALENDAR&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;The service retrieves this calendar from the endpoint, parses it and
extracts a list of events together with metadata from it.  It then
serves the data in a Prometheus-compatible time series API.  Clients
can request all upcoming events using the following call:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="err"&gt;GET /api/v1/query?query=events&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;To which the service returns the time series of events:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;status&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;success&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;data&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;resultType&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;vector&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;result&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;metric&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;__name__&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;event&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;calendar&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;0&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;uid&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;20190603T032500CEST-foo&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;summary&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Foo&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;description&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;An example event&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;value&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="mi"&gt;1560043497&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;metric&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;__name__&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;event&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;calendar&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;1&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;uid&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;20190603T032500CEST-bar&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;summary&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Bar&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;description&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Another example event&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;value&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="mi"&gt;1560043497&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;h2 id="caveats"&gt;Caveats&lt;/h2&gt;
&lt;p&gt;Since a Prometheus label can't be used multiple times, event
categories can't be easily mapped to them.  Thus, event categories are
currently not exported.  If someone has an idea how to model
categories in the output, while keeping it easy to query and manage,
feel free to contact me.&lt;/p&gt;
&lt;p&gt;Grafana uses a hardcoded &lt;code&gt;1+1&lt;/code&gt; query to test Prometheus data sources,
so the API currently has a special check for that and returns &lt;code&gt;2&lt;/code&gt;, as
expected by Grafana.&lt;/p&gt;
&lt;h2 id="interested"&gt;Interested?&lt;/h2&gt;
&lt;p&gt;The project, which I named &lt;em&gt;iCalendar Timeseries Server&lt;/em&gt;, can be found
on &lt;a href="https://gitlab.com/s3lph/icalendar-timeseries-server"&gt;Gitlab&lt;/a&gt;.  Each release comes with Python Wheel and Debian
packages.&lt;/p&gt;</content><category term="hacks"/><category term="iCalendar"/><category term="Webcal"/><category term="Calendar"/><category term="Grafana"/><category term="Prometheus"/><category term="Python"/></entry><entry><title>Automatically Rotating Guest WiFi Passwords With hostapd</title><link href="https://s3lph.me/automatically-rotating-guest-wifi-passwords-with-hostapd.html" rel="alternate"/><published>2020-02-06T02:28:00+01:00</published><updated>2020-02-06T02:28:00+01:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2020-02-06:/automatically-rotating-guest-wifi-passwords-with-hostapd.html</id><summary type="html">&lt;p&gt;I like to have control over who gets on my networks and who doesn't.&lt;/p&gt;
&lt;p&gt;To obtain this level of control in my home network, I'm running a
separate WiFi for guests, which among other things separates guest
devices from my private infrastructure.&lt;/p&gt;
&lt;h2 id="authorization-in-hostapd"&gt;Authorization in hostapd&lt;/h2&gt;
&lt;p&gt;The most simple way of …&lt;/p&gt;</summary><content type="html">&lt;p&gt;I like to have control over who gets on my networks and who doesn't.&lt;/p&gt;
&lt;p&gt;To obtain this level of control in my home network, I'm running a
separate WiFi for guests, which among other things separates guest
devices from my private infrastructure.&lt;/p&gt;
&lt;h2 id="authorization-in-hostapd"&gt;Authorization in hostapd&lt;/h2&gt;
&lt;p&gt;The most simple way of configuring WPA2-PSK authorization in hostapd
is a static passphrase:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="c1"&gt;# /etc/hostapd/hostapd.conf&lt;/span&gt;
&lt;span class="na"&gt;wpa&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;2&lt;/span&gt;
&lt;span class="na"&gt;wpa_key_mgmt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;WPA-PSK&lt;/span&gt;
&lt;span class="na"&gt;wpa_passphrase&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Nobody expects the Spanish Inquisition!&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;So far, so good - but once a person knows this passphrase, they can
get on my WiFi all the time, and they could share the passphrase with
other people.  This way, I lose control over who gets on my networks.&lt;/p&gt;
&lt;p&gt;hostapd also supports device-specific passphrases, configured in a
separate file:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="c1"&gt;# /etc/hostapd/hostapd.conf&lt;/span&gt;
&lt;span class="na"&gt;wpa&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;2&lt;/span&gt;
&lt;span class="na"&gt;wpa_key_mgmt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;WPA-PSK&lt;/span&gt;
&lt;span class="na"&gt;wpa_psk_file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/etc/hostapd/hostapd.wpa_psk&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Now, how should this file look like?  The hostapd &lt;abbr
title="commented sample config
file"&gt;&lt;a href="https://w1.fi/cgit/hostap/plain/hostapd/hostapd.conf"&gt;"documentation"&lt;/a&gt;&lt;/abbr&gt; is a bit shady in this
regard, and only mentions &lt;code&gt;(PSK,MAC address) pairs&lt;/code&gt;; the exact format
is not mentioned.  However, multiple sources on the internet seem to
agree on this format:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="gh"&gt;#&lt;/span&gt; /etc/hostapd/hostapd.wpa_psk
ma:ca:dd:re:ss:00 The Passphrase For Device A
ma:ca:dd:re:ss:01 The Passphrase For Device B
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;And, most important, some sources also mention that the MAC address
&lt;code&gt;00:00:00:00:00:00&lt;/code&gt; can be used as a wildcard, so the associated
passphrase works for all devices.  This alone does not give us any
advantage over the hardcoded passphrase.  However, having the
passphrase in a separate file makes automated rotation extremely easy.
By doing this, I have a fairly good control over who can access my
guest WiFi when the passphrase is rotated frequently through a
cronjob.&lt;/p&gt;
&lt;p&gt;To take things a step further, we can decouple the passphrases
rotation rate from how long a passphrase remains valid.  As it turns
out, the wildcard MAC address can be used multiple times, and all
wildcard passphrases are accepted.  This allows us to do the following:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Generate a new passphrase once a day&lt;/li&gt;
&lt;li&gt;Add the new passphrase as a wildcard entry to the wpa_psk file&lt;/li&gt;
&lt;li&gt;Remove all but the seven newest entries from the file&lt;/li&gt;
&lt;li&gt;Reload hostapd&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So, this gives us a new passphrase every day, and each passphrase
remains valid for a week.&lt;/p&gt;
&lt;h2 id="giving-the-passphrase-to-guests"&gt;Giving the Passphrase to Guests&lt;/h2&gt;
&lt;p&gt;I'm using &lt;code&gt;qrencode&lt;/code&gt; to generate a QR code with the latest passphrase,
and display the result, together with its plaintext form, in a Grafana
HTML panel:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;qrencode&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;-t&lt;span class="w"&gt; &lt;/span&gt;PNG&lt;span class="w"&gt; &lt;/span&gt;--size&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;6&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;--output&lt;span class="o"&gt;=&lt;/span&gt;/var/www/html/wifi-guest.png&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;WIFI:S:&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SSID&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;;T:WPA2;P:&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PASSPHRASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;;;&amp;quot;&lt;/span&gt;

cat&lt;span class="w"&gt; &lt;/span&gt;&amp;gt;&lt;span class="w"&gt; &lt;/span&gt;/var/www/html/wifi-guest.html&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;lt;&amp;lt;EOF&lt;/span&gt;
&lt;span class="s"&gt;  &amp;lt;!-- Timestamp for browser cache circumvention --&amp;gt;&lt;/span&gt;
&lt;span class="s"&gt;  &amp;lt;img src=&amp;quot;/wifi-guest.png?$(date +%s)&amp;quot; /&amp;gt;&lt;/span&gt;
&lt;span class="s"&gt;  &amp;lt;br/&amp;gt;&amp;lt;br/&amp;gt;&amp;lt;br/&amp;gt;&lt;/span&gt;
&lt;span class="s"&gt;  &amp;lt;h3&amp;gt;&amp;lt;tt&amp;gt;${SSID}&amp;lt;/tt&amp;gt;&amp;lt;/h3&amp;gt;&lt;/span&gt;
&lt;span class="s"&gt;  &amp;lt;h1&amp;gt;&amp;lt;tt&amp;gt;${PASSPHRASE}&amp;lt;/tt&amp;gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
&lt;span class="s"&gt;EOF&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;And the result looks like this:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Screenshot of a Grafana Panel with QR code, WiFi SSID and
Passphrase" src="https://s3lph.me/images/2020-02-06-hostapd-rotating-passwords-grafana.png"&gt;&lt;/p&gt;</content><category term="hacks"/><category term="WiFi"/><category term="hostapd"/><category term="Grafana"/></entry><entry><title>Bringing Swiss Public Transport Departures to Grafana</title><link href="https://s3lph.me/bringing-swiss-public-transport-departures-to-grafana.html" rel="alternate"/><published>2020-02-05T02:26:00+01:00</published><updated>2020-02-05T02:26:00+01:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2020-02-05:/bringing-swiss-public-transport-departures-to-grafana.html</id><summary type="html">&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Update (2020-11-27)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The API endpoint used here has been deprecated, and a &lt;a href="https://opentransportdata.swiss/en/cookbook/ojp-stopeventservice/"&gt;new endpoint&lt;/a&gt; is available.
The updated script can be found on &lt;a href="https://gitlab.com/-/snippets/2043551"&gt;Gitlab&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;The Swiss Railways (SBB) provide a collection of static data sets and
dynamic APIs at &lt;a href="https://opentransportdata.swiss/en/"&gt;opentransportdata.swiss&lt;/a&gt;.  One endpoint
provides a list of departures or arrivals …&lt;/p&gt;</summary><content type="html">&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Update (2020-11-27)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The API endpoint used here has been deprecated, and a &lt;a href="https://opentransportdata.swiss/en/cookbook/ojp-stopeventservice/"&gt;new endpoint&lt;/a&gt; is available.
The updated script can be found on &lt;a href="https://gitlab.com/-/snippets/2043551"&gt;Gitlab&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;The Swiss Railways (SBB) provide a collection of static data sets and
dynamic APIs at &lt;a href="https://opentransportdata.swiss/en/"&gt;opentransportdata.swiss&lt;/a&gt;.  One endpoint
provides a list of departures or arrivals for a given train, bus or
tram station.&lt;/p&gt;
&lt;p&gt;In this blogpost, I'm showing you how I'm using this API to get a list
of upcoming departures for the station next to my home, and how do get
this list into Grafana.&lt;/p&gt;
&lt;h2 id="the-api"&gt;The API&lt;/h2&gt;
&lt;p&gt;The &lt;abbr title=":sigh:"&gt;XML&lt;/abbr&gt; API is documented in the &lt;a href="https://opentransportdata.swiss/en/cookbook/departurearrival-display/"&gt;"API
Cookbook"&lt;/a&gt;.  A request looks like this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;POST&lt;span class="w"&gt; &lt;/span&gt;/trias&lt;span class="w"&gt; &lt;/span&gt;HTTP/1.1
Host:&lt;span class="w"&gt; &lt;/span&gt;https://api.opentransportdata.swiss
Authorization:&lt;span class="w"&gt; &lt;/span&gt;TOKEN
Content-Type:&lt;span class="w"&gt; &lt;/span&gt;text/xml

&lt;span class="cp"&gt;&amp;lt;?xml version=&amp;quot;1.0&amp;quot; encoding=&amp;quot;UTF-8&amp;quot;?&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;Trias&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="na"&gt;version=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;1.1&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="na"&gt;xmlns=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;http://www.vdv.de/trias&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="na"&gt;xmlns:siri=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;http://www.siri.org.uk/siri&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="na"&gt;xmlns:xsi=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;http://www.w3.org/2001/XMLSchema-instance&amp;quot;&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;ServiceRequest&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;siri:RequestTimestamp&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;NOW&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/siri:RequestTimestamp&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;siri:RequestorRef&amp;gt;&lt;/span&gt;EPSa&lt;span class="nt"&gt;&amp;lt;/siri:RequestorRef&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;RequestPayload&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;StopEventRequest&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;Location&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;LocationRef&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;StopPointRef&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;BPUIC&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/StopPointRef&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/LocationRef&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;DepArrTime&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;NOW&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/DepArrTime&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/Location&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;Params&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;NumberOfResults&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;N_RESULTS&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/NumberOfResults&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;StopEventType&amp;gt;&lt;/span&gt;departure&lt;span class="nt"&gt;&amp;lt;/StopEventType&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;IncludePreviousCalls&amp;gt;&lt;/span&gt;false&lt;span class="nt"&gt;&amp;lt;/IncludePreviousCalls&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;IncludeOnwardCalls&amp;gt;&lt;/span&gt;false&lt;span class="nt"&gt;&amp;lt;/IncludeOnwardCalls&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;IncludeRealtimeData&amp;gt;&lt;/span&gt;true&lt;span class="nt"&gt;&amp;lt;/IncludeRealtimeData&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/Params&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/StopEventRequest&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/RequestPayload&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/ServiceRequest&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/Trias&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;This request is fairly minimal; it is limited to a single station, and
without further information such as previous and following stops.  You
only need to fill in the following arguments to make this work for
your station of choice:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;TOKEN&lt;/strong&gt;: API Token, need to register an account.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;NOW&lt;/strong&gt; (2x): Current time in ISO-8601 form.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;BPUIC&lt;/strong&gt;: Numeric ID of the station ("Betriebspunkt"), can be
  looked up in the &lt;a href="https://opentransportdata.swiss/en/dataset/didok"&gt;DiDok&lt;/a&gt; dataset.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;N_RESULTS&lt;/strong&gt;: Maximal number of results to return.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="prometheus-ingestion"&gt;Prometheus Ingestion&lt;/h2&gt;
&lt;p&gt;XML is a bit... let's say, &lt;em&gt;uncomfortable&lt;/em&gt; to handle in Bash scripts,
so I resorted to using the &lt;code&gt;xsltproc&lt;/code&gt; tool to transform the API
response into something easily iterable; the XSLT document i came up
with looks like this and generates CSV content:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?xml version=&amp;quot;1.0&amp;quot; encoding=&amp;quot;utf-8&amp;quot;?&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;xsl:stylesheet&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="na"&gt;version=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;1.0&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="na"&gt;xmlns:xsl=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;http://www.w3.org/1999/XSL/Transform&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="na"&gt;xmlns:trias=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;http://www.vdv.de/trias&amp;quot;&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;xsl:output&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;method=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;text&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;xsl:template&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;match=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;/&amp;quot;&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;xsl:for-each&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;select=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;//trias:StopEvent&amp;quot;&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;xsl:value-of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;select=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;trias:Service/trias:PublishedLineName/trias:Text&amp;quot;&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;xsl:text&amp;gt;&lt;/span&gt;;&lt;span class="nt"&gt;&amp;lt;/xsl:text&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;xsl:value-of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;select=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;trias:Service/trias:DestinationText/trias:Text&amp;quot;&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;xsl:text&amp;gt;&lt;/span&gt;;&lt;span class="nt"&gt;&amp;lt;/xsl:text&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;xsl:value-of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;select=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;trias:ThisCall/trias:CallAtStop/trias:ServiceDeparture/trias:TimetabledTime&amp;quot;&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;xsl:text&amp;gt;&lt;/span&gt;;&lt;span class="nt"&gt;&amp;lt;/xsl:text&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;xsl:value-of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;select=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;trias:ThisCall/trias:CallAtStop/trias:ServiceDeparture/trias:EstimatedTime&amp;quot;&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;xsl:text&amp;gt;&lt;/span&gt;&lt;span class="ni"&gt;&amp;amp;#x0A;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/xsl:text&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/xsl:for-each&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/xsl:template&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/xsl:stylesheet&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Each line in the result represents a stop at the station, with the following fields:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Number of the train or bus line&lt;/li&gt;
&lt;li&gt;Name of the destination&lt;/li&gt;
&lt;li&gt;Scheduled departure time&lt;/li&gt;
&lt;li&gt;Estimated/actual departure time&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This format is quite easy to handle in Bash; let's parse the ISO-8601
timestamps, compute the delay for each stop and then emit the results
in Prometheus collector format:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="gh"&gt;#&lt;/span&gt; TYPE sbb_station_departure gauge
&lt;span class="gh"&gt;#&lt;/span&gt; HELP sbb_station_departure Departures from a train or bus station
&lt;span class="gh"&gt;#&lt;/span&gt; TYPE sbb_station_delay gauge
&lt;span class="gh"&gt;#&lt;/span&gt; HELP sbb_station_delay Departure delay
sbb_station_departure{line=&amp;quot;26&amp;quot;,planned=&amp;quot;1580875380000&amp;quot;,destination=&amp;quot;Erstfeld&amp;quot;} 1580875380000
sbb_station_delay{line=&amp;quot;26&amp;quot;,planned=&amp;quot;1580875380000&amp;quot;,destination=&amp;quot;Erstfeld&amp;quot;} 0
sbb_station_departure{line=&amp;quot;36&amp;quot;,planned=&amp;quot;1580875980000&amp;quot;,destination=&amp;quot;Zürich HB&amp;quot;} 1580875980000
sbb_station_delay{line=&amp;quot;36&amp;quot;,planned=&amp;quot;1580875980000&amp;quot;,destination=&amp;quot;Zürich HB&amp;quot;} 0
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;I'm using the &lt;a href="https://github.com/prometheus-community/node-exporter-textfile-collector-scripts"&gt;Textfile Collector&lt;/a&gt; feature of the Prometheus
Node Exporter to ingest this document into Prometheus.&lt;/p&gt;
&lt;h2 id="display-in-grafana"&gt;Display in Grafana&lt;/h2&gt;
&lt;p&gt;I'm showing this data in a table panel in Grafana, using two queries:
one for the scheduled departure, one for the delay.  Here, you can
filter the departures by destination, if not already done in your
script:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;min without (__name__) (sbb_station_departure{destination=~&amp;quot;.*Zürich.*&amp;quot;})
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;and&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;min without (__name__) (sbb_station_delay{destination=~&amp;quot;.*Zürich.*&amp;quot;})
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;And finally, after some styling, the result looks like this:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Screenshot of a Grafana table panel with a train schedule" src="https://s3lph.me/images/2020-05-02-sbb-stationboard-grafana.png"&gt;&lt;/p&gt;
&lt;p&gt;The code can be found on &lt;a href="https://gitlab.com/-/snippets/2043551"&gt;Gitlab&lt;/a&gt;.&lt;/p&gt;</content><category term="hacks"/><category term="SBB"/><category term="Public Transport"/><category term="Open Data"/><category term="Grafana"/><category term="Prometheus"/></entry><entry><title>SBB-Abfahrtszeiten in Grafana anzeigen</title><link href="https://s3lph.me/bringing-swiss-public-transport-departures-to-grafana-de.html" rel="alternate"/><published>2020-02-05T02:26:00+01:00</published><updated>2020-02-05T02:26:00+01:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2020-02-05:/bringing-swiss-public-transport-departures-to-grafana-de.html</id><summary type="html">&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Update (2020-11-27)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Der hier verwendete API-Endpunkt ist veraltet und wurde durch einen &lt;a href="https://opentransportdata.swiss/de/cookbook/ojpstopeventrequest/"&gt;neuen Endpunkt&lt;/a&gt; ersetzt.
Das aktualisierte Skript liegt auf &lt;a href="https://gitlab.com/-/snippets/2043551"&gt;Gitlab&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Die SBB bieten unter &lt;a href="https://opentransportdata.swiss/de/"&gt;opentransportdata.swiss&lt;/a&gt; eine Sammlung an
statischen Datensätzen und dynamischen APIs an.  Ein Endpunkt bietet
eine Liste an Zug-, Bus- oder Tramabfahrten oder -Ankünften für
gewisse …&lt;/p&gt;</summary><content type="html">&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Update (2020-11-27)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Der hier verwendete API-Endpunkt ist veraltet und wurde durch einen &lt;a href="https://opentransportdata.swiss/de/cookbook/ojpstopeventrequest/"&gt;neuen Endpunkt&lt;/a&gt; ersetzt.
Das aktualisierte Skript liegt auf &lt;a href="https://gitlab.com/-/snippets/2043551"&gt;Gitlab&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Die SBB bieten unter &lt;a href="https://opentransportdata.swiss/de/"&gt;opentransportdata.swiss&lt;/a&gt; eine Sammlung an
statischen Datensätzen und dynamischen APIs an.  Ein Endpunkt bietet
eine Liste an Zug-, Bus- oder Tramabfahrten oder -Ankünften für
gewisse Haltestellen an.&lt;/p&gt;
&lt;p&gt;In diesem Blogpost erkläre ich, wie ich diese API verwende, um eine
Liste an kommenden Abfahrten ab meinem "Heimatbahnhof" zu erhalten,
und wie diese Liste in Grafana landet.&lt;/p&gt;
&lt;h2 id="die-api"&gt;Die API&lt;/h2&gt;
&lt;p&gt;Die &lt;abbr title=":sigh:"&gt;XML&lt;/abbr&gt;-API ist im &lt;a href="https://opentransportdata.swiss/de/cookbook/abfahrts-ankunftsanzeiger/"&gt;"API
Cookbook"&lt;/a&gt; dokumentiert.  Eine Anfrage an die API sieht
etwas so aus:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;POST&lt;span class="w"&gt; &lt;/span&gt;/trias&lt;span class="w"&gt; &lt;/span&gt;HTTP/1.1
Host:&lt;span class="w"&gt; &lt;/span&gt;https://api.opentransportdata.swiss
Authorization:&lt;span class="w"&gt; &lt;/span&gt;TOKEN
Content-Type:&lt;span class="w"&gt; &lt;/span&gt;text/xml

&lt;span class="cp"&gt;&amp;lt;?xml version=&amp;quot;1.0&amp;quot; encoding=&amp;quot;UTF-8&amp;quot;?&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;Trias&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="na"&gt;version=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;1.1&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="na"&gt;xmlns=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;http://www.vdv.de/trias&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="na"&gt;xmlns:siri=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;http://www.siri.org.uk/siri&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="na"&gt;xmlns:xsi=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;http://www.w3.org/2001/XMLSchema-instance&amp;quot;&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;ServiceRequest&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;siri:RequestTimestamp&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;NOW&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/siri:RequestTimestamp&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;siri:RequestorRef&amp;gt;&lt;/span&gt;EPSa&lt;span class="nt"&gt;&amp;lt;/siri:RequestorRef&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;RequestPayload&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;StopEventRequest&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;Location&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;LocationRef&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;StopPointRef&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;BPUIC&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/StopPointRef&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/LocationRef&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;DepArrTime&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;NOW&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/DepArrTime&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/Location&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;Params&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;NumberOfResults&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;N_RESULTS&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/NumberOfResults&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;StopEventType&amp;gt;&lt;/span&gt;departure&lt;span class="nt"&gt;&amp;lt;/StopEventType&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;IncludePreviousCalls&amp;gt;&lt;/span&gt;false&lt;span class="nt"&gt;&amp;lt;/IncludePreviousCalls&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;IncludeOnwardCalls&amp;gt;&lt;/span&gt;false&lt;span class="nt"&gt;&amp;lt;/IncludeOnwardCalls&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;IncludeRealtimeData&amp;gt;&lt;/span&gt;true&lt;span class="nt"&gt;&amp;lt;/IncludeRealtimeData&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/Params&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/StopEventRequest&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/RequestPayload&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/ServiceRequest&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/Trias&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Diese Anfrage ist sehr minimalistisch; sie beschränkt sich auf eine
einzelne Haltestelle, und ignoriert weitere Informationen wie
Zwischenhalte.  Hier muss man einfach nur die folgenden Argumente
einfügen, um die API für die "eigene" Haltestelle zu verwenden:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;TOKEN&lt;/strong&gt;: Das API Token, man muss einen Benutzeraccount anlegen.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;NOW&lt;/strong&gt; (2x): Aktueller Zeitpunkt in ISO-8601-Form.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;BPUIC&lt;/strong&gt;: Numerische ID der Haltestelle ("Betriebspunkt"), kann im &lt;a href="https://opentransportdata.swiss/de/dataset/didok"&gt;DiDok&lt;/a&gt;-Datensatz nachgeschlagen werden.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;N_RESULTS&lt;/strong&gt;: Maximale Anzahl an zurückgegebenen Ergebnissen.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="daten-ins-prometheus-bekommen"&gt;Daten ins Prometheus bekommen&lt;/h2&gt;
&lt;p&gt;XML ist ein bisschen... &lt;em&gt;mühsam&lt;/em&gt; in Bash-Scripten zu verwenden, daher
habe ich hier das &lt;code&gt;xsltproc&lt;/code&gt;-Tool zur Hilfe genommen, um die Antwort
der API in ein handlicheres Format zu übersetzen; das XSLT-Dokument, das hierfür entstanden ist, sieht so aus und generiert eine Ausgabe in CSV-Form:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?xml version=&amp;quot;1.0&amp;quot; encoding=&amp;quot;utf-8&amp;quot;?&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;xsl:stylesheet&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="na"&gt;version=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;1.0&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="na"&gt;xmlns:xsl=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;http://www.w3.org/1999/XSL/Transform&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="na"&gt;xmlns:trias=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;http://www.vdv.de/trias&amp;quot;&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;xsl:output&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;method=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;text&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;xsl:template&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;match=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;/&amp;quot;&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;xsl:for-each&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;select=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;//trias:StopEvent&amp;quot;&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;xsl:value-of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;select=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;trias:Service/trias:PublishedLineName/trias:Text&amp;quot;&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;xsl:text&amp;gt;&lt;/span&gt;;&lt;span class="nt"&gt;&amp;lt;/xsl:text&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;xsl:value-of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;select=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;trias:Service/trias:DestinationText/trias:Text&amp;quot;&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;xsl:text&amp;gt;&lt;/span&gt;;&lt;span class="nt"&gt;&amp;lt;/xsl:text&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;xsl:value-of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;select=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;trias:ThisCall/trias:CallAtStop/trias:ServiceDeparture/trias:TimetabledTime&amp;quot;&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;xsl:text&amp;gt;&lt;/span&gt;;&lt;span class="nt"&gt;&amp;lt;/xsl:text&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;xsl:value-of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;select=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;trias:ThisCall/trias:CallAtStop/trias:ServiceDeparture/trias:EstimatedTime&amp;quot;&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;xsl:text&amp;gt;&lt;/span&gt;&lt;span class="ni"&gt;&amp;amp;#x0A;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/xsl:text&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/xsl:for-each&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/xsl:template&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/xsl:stylesheet&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Jede Zeile der Ausgabe repräsentiert einen Halt an der Haltestelle,
mit den folgenden Datenfeldern:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Nummer der Bus- oder Zuglinie&lt;/li&gt;
&lt;li&gt;Name des Fahrtziels&lt;/li&gt;
&lt;li&gt;Fahrplanmässige Abfahrtszeit&lt;/li&gt;
&lt;li&gt;Tatsächliche/geschätzte Abfahrtszeit&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Dieses Format lässt sich in Bash deutlich einfacher verarbeiten; für
jeden Halt werden die ISO-8601-Timestamps geparst, daraus die
Verspätung berechnet, und das Ergebnis im Prometheus-Collector-Format
ausgegeben:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="gh"&gt;#&lt;/span&gt; TYPE sbb_station_departure gauge
&lt;span class="gh"&gt;#&lt;/span&gt; HELP sbb_station_departure Departures from a train or bus station
&lt;span class="gh"&gt;#&lt;/span&gt; TYPE sbb_station_delay gauge
&lt;span class="gh"&gt;#&lt;/span&gt; HELP sbb_station_delay Departure delay
sbb_station_departure{line=&amp;quot;26&amp;quot;,planned=&amp;quot;1580875380000&amp;quot;,destination=&amp;quot;Erstfeld&amp;quot;} 1580875380000
sbb_station_delay{line=&amp;quot;26&amp;quot;,planned=&amp;quot;1580875380000&amp;quot;,destination=&amp;quot;Erstfeld&amp;quot;} 0
sbb_station_departure{line=&amp;quot;36&amp;quot;,planned=&amp;quot;1580875980000&amp;quot;,destination=&amp;quot;Zürich HB&amp;quot;} 1580875980000
sbb_station_delay{line=&amp;quot;36&amp;quot;,planned=&amp;quot;1580875980000&amp;quot;,destination=&amp;quot;Zürich HB&amp;quot;} 0
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Um diese Daten in Prometheus zu importieren, verwende ich das
&lt;a href="https://github.com/prometheus-community/node-exporter-textfile-collector-scripts"&gt;Textfile Collector&lt;/a&gt;-Feature des Prometheus Node Exporter.&lt;/p&gt;
&lt;h2 id="anzeige-in-grafana"&gt;Anzeige in Grafana&lt;/h2&gt;
&lt;p&gt;Die Abfahrtsdaten lassen sich in Grafana in einer Tabelle darstellen.
Hierzu werden zwei Prometheus-Queries benötigt; eine für die
planmässige Abfahrt, eine für die Verspätung.  Wenn man nach
bestimmten Fahrtzielen filtern möchte, lässt sich dies zum Beispiel
hier tun:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;min without (__name__) (sbb_station_departure{destination=~&amp;quot;.*Zürich.*&amp;quot;})
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;und&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;min without (__name__) (sbb_station_delay{destination=~&amp;quot;.*Zürich.*&amp;quot;})
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Nach ein bisschen Styling sieht das ganze dann so aus:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Screenshot of a Grafana table panel with a train schedule" src="https://s3lph.me/images/2020-05-02-sbb-stationboard-grafana.png"&gt;&lt;/p&gt;
&lt;p&gt;Der Code hierzu ist auf &lt;a href="https://gitlab.com/-/snippets/2043551"&gt;Gitlab&lt;/a&gt; zu finden.&lt;/p&gt;</content><category term="hacks"/><category term="SBB"/><category term="Public Transport"/><category term="Open Data"/><category term="Grafana"/><category term="Prometheus"/></entry><entry><title>Monitoring Freifunk Nodes With Prometheus</title><link href="https://s3lph.me/monitoring-freifunk-nodes-with-prometheus.html" rel="alternate"/><published>2020-02-05T01:36:00+01:00</published><updated>2020-02-05T01:36:00+01:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2020-02-05:/monitoring-freifunk-nodes-with-prometheus.html</id><summary type="html">&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Update (2020-03-07)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;We now collect the number of connected clients as well as whether the node is online.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;We recently installed a Freifunk node from &lt;a href="https://freifunk-3laendereck.net/"&gt;Freifunk Dreiländereck
(FF3L)&lt;/a&gt; in our hackerspace.  While changing network
configuration during the testing phase, the node went offline without
us noticing.  Since we're using &lt;a href="https://prometheus.io/"&gt;Prometheus …&lt;/a&gt;&lt;/p&gt;</summary><content type="html">&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Update (2020-03-07)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;We now collect the number of connected clients as well as whether the node is online.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;We recently installed a Freifunk node from &lt;a href="https://freifunk-3laendereck.net/"&gt;Freifunk Dreiländereck
(FF3L)&lt;/a&gt; in our hackerspace.  While changing network
configuration during the testing phase, the node went offline without
us noticing.  Since we're using &lt;a href="https://prometheus.io/"&gt;Prometheus&lt;/a&gt; for
monitoring our space's infrastructure, I went ahead and hacked
together a solution which I want to present here:&lt;/p&gt;
&lt;h2 id="idea"&gt;Idea&lt;/h2&gt;
&lt;p&gt;Instead of monitoring the node directly, I decided to tap into the
status information already collected by the Freifunk community.  They
are publishing some interesting statistics, however we only really
cared about one information: Whether FF3L currently considers the node
to be online and reachable.&lt;/p&gt;
&lt;p&gt;FF3L publishes these status information at the following endpoint:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://map.freifunk-3laendereck.net/data/nodes.json"&gt;https://map.freifunk-3laendereck.net/data/nodes.json&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Many (if not all?) Freifunk communities provide such an endpoint,
though with some you may have to search for a while to find it.&lt;/p&gt;
&lt;h2 id="implementation"&gt;Implementation&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;nodes&lt;/code&gt; endpoint yields the information of &lt;strong&gt;all&lt;/strong&gt; nodes at once.
Unfortunately, I didn't find a way to reduce the request to specific
nodes; if someone knows more about this, don't hesitate to tell
me. (As far as I can tell there are multiple implementations of this
endpoint, some of which appear to support filters.)&lt;/p&gt;
&lt;p&gt;This is how a single node object from the API response looks like:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;nodeinfo&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;software&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;firmware&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;base&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;gluon-v2019.1&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;release&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;v2019.1.0+001&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;network&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;location&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;latitude&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;longitude&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;system&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;role&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;node&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;site_code&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;ff3l&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;domain_code&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;3land&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;node_id&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;...&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;hostname&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;...&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;flags&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;online&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;statistics&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;uptime&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;626614.08&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;clients&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;lastseen&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;...&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;firstseen&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;...&amp;quot;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;We were especially interest in the &lt;code&gt;.flags.online&lt;/code&gt; and
&lt;code&gt;.statistics.clients&lt;/code&gt; fields; our implementation extracts nothing but
these two field.  The fields &lt;code&gt;.nodeinfo.node_id&lt;/code&gt; and
&lt;code&gt;.nodeinfo.hostname&lt;/code&gt; are suitable for filtering for your own nodes.&lt;/p&gt;
&lt;p&gt;Using a bit of "&lt;code&gt;curl|jq&lt;/code&gt; magic", we can create a shell script for
parsing the data and converting it into a format understood by
Prometheus.  We added the script as a &lt;a href="https://github.com/prometheus-community/node-exporter-textfile-collector-scripts"&gt;Textfile Collector&lt;/a&gt;
to an existing Prometheus Node Exporter instance.  The output then
looks like this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="gh"&gt;#&lt;/span&gt; HELP freifunk_node_online 1 if the Freifunk node is online, 0 otherwise
&lt;span class="gh"&gt;#&lt;/span&gt; TYPE freifunk_node_online gauge
&lt;span class="gh"&gt;#&lt;/span&gt; HELP ff3l_node_clients Number of clients connected to the node
&lt;span class="gh"&gt;#&lt;/span&gt; TYPE ff3l_node_clients gauge
freifunk_node_online{node=&amp;quot;&amp;lt;node0_id&amp;gt;&amp;quot;,hostname=&amp;quot;&amp;lt;node0_hostname&amp;gt;&amp;quot;} 1
freifunk_node_clients{node=&amp;quot;&amp;lt;node0_id&amp;gt;&amp;quot;,hostname=&amp;quot;&amp;lt;node0_hostname&amp;gt;&amp;quot;} 2
freifunk_node_online{node=&amp;quot;&amp;lt;node1_id&amp;gt;&amp;quot;,hostname=&amp;quot;&amp;lt;node1_hostname&amp;gt;&amp;quot;} 0
freifunk_node_clients{node=&amp;quot;&amp;lt;node1_id&amp;gt;&amp;quot;,hostname=&amp;quot;&amp;lt;node1_hostname&amp;gt;&amp;quot;} 0
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;The resulting script can be found on &lt;a href="https://gitlab.com/snippets/1937620"&gt;Gitlab&lt;/a&gt;.&lt;/p&gt;</content><category term="hacks"/><category term="Freifunk"/><category term="Monitoring"/><category term="Prometheus"/></entry><entry><title>Freifunkknoten mit Prometheus überwachen</title><link href="https://s3lph.me/monitoring-freifunk-nodes-with-prometheus-de.html" rel="alternate"/><published>2020-02-05T01:36:00+01:00</published><updated>2020-02-05T01:36:00+01:00</updated><author><name>s3lph</name></author><id>tag:s3lph.me,2020-02-05:/monitoring-freifunk-nodes-with-prometheus-de.html</id><summary type="html">&lt;p&gt;&lt;strong&gt;Aktualisiert&lt;/strong&gt; 2020-03-07: Wir erfassen inzwischen, zusätzlich zur
Verfügbarkeit der Node, wieviele aktuell verbunden sind.&lt;/p&gt;
&lt;p&gt;Wir haben in unserem Hackerspace neulich einen Freifunk-Knoten von
&lt;a href="https://freifunk-3laendereck.net/"&gt;Freifunk Dreiländereck (FF3L)&lt;/a&gt; in Betrieb genommen.  Dieser ist
uns in der Probephase mehrfach bei Änderungen der
Netzwerkkonfiguration offline gegangen, ohne das wir es gemerkt
hatten.  Da wir …&lt;/p&gt;</summary><content type="html">&lt;p&gt;&lt;strong&gt;Aktualisiert&lt;/strong&gt; 2020-03-07: Wir erfassen inzwischen, zusätzlich zur
Verfügbarkeit der Node, wieviele aktuell verbunden sind.&lt;/p&gt;
&lt;p&gt;Wir haben in unserem Hackerspace neulich einen Freifunk-Knoten von
&lt;a href="https://freifunk-3laendereck.net/"&gt;Freifunk Dreiländereck (FF3L)&lt;/a&gt; in Betrieb genommen.  Dieser ist
uns in der Probephase mehrfach bei Änderungen der
Netzwerkkonfiguration offline gegangen, ohne das wir es gemerkt
hatten.  Da wir in unserem Space &lt;a href="https://prometheus.io/"&gt;Prometheus&lt;/a&gt; zur
Überwachung unserer Infrastruktur einsetzen, habe ich kurzerhand eine
entsprechende Lösung gebastelt, die ich hier kurz vorstellen will:&lt;/p&gt;
&lt;h2 id="idee"&gt;Idee&lt;/h2&gt;
&lt;p&gt;Anstatt den Knoten direkt zu überwachen, habe ich mich dazu
entschieden, die von der Freifunk-Community gesammelten
Statusinformationen anzuzapfen.  Dort fallen einige interessante
Statistiken heraus, aber für uns war ein einziger Wert relevant: Ist
der Freifunkknoten laut FF3L momentan online und erreichbar?&lt;/p&gt;
&lt;p&gt;Die Statusinformationen lassen sich bei FF3L über den folgenden
Endpoint abfragen:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://map.freifunk-3laendereck.net/data/nodes.json"&gt;https://map.freifunk-3laendereck.net/data/nodes.json&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Viele (vielleicht sogar alle?) Freifunk-Communities haben einen
solchen Endpoint, bei manchen muss man eventuell aber etwas suchen,
bevor man die API findet.&lt;/p&gt;
&lt;h2 id="umsetzung"&gt;Umsetzung&lt;/h2&gt;
&lt;p&gt;Der &lt;code&gt;nodes&lt;/code&gt;-Endpoint liefert auf einen Schlag die Informationen zu
&lt;strong&gt;allen&lt;/strong&gt; Knoten.  Ich habe leider keinen Weg gefunden, die Anfrage
direkt auf einzelne Knoten zu reduzieren; wenn hier jemand mehr weiss,
würde ich mich über Infos dazu freuen. (Soweit ich das sehen kann,
gibt es verschiedene Implementationen, manche scheinen Filter zu
unterstützen.)&lt;/p&gt;
&lt;p&gt;Für einen einzelnen Knoten sieht diese API-Response etwa so aus:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;nodeinfo&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;software&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;firmware&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;base&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;gluon-v2019.1&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;release&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;v2019.1.0+001&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;network&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;location&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;latitude&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;longitude&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;system&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;role&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;node&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;site_code&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;ff3l&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;domain_code&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;3land&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;node_id&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;...&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;hostname&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;...&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;flags&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;online&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;statistics&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;uptime&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;626614.08&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;clients&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;lastseen&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;...&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;firstseen&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;...&amp;quot;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Für uns waren hier vor allem die Felder &lt;code&gt;.flags.online&lt;/code&gt; und
&lt;code&gt;.statistics.clients&lt;/code&gt; interessant; mehr lesen wir in unserer
Implementation gar nicht aus.  Die Felder &lt;code&gt;.nodeinfo.node_id&lt;/code&gt; und
&lt;code&gt;.nodeinfo.hostname&lt;/code&gt; eignen sich, um nach den eigenen Knoten zu
filtern.&lt;/p&gt;
&lt;p&gt;Mit ein bisschen "&lt;code&gt;curl|jq&lt;/code&gt;-Magie" lässt sich das ganze recht einfach
in einem Shellscript parsen und in ein Format bringen, mit dem
Prometheus etwas anfangen kann.  Wir haben das ganze als &lt;a href="https://github.com/prometheus-community/node-exporter-textfile-collector-scripts"&gt;Textfile
Collector&lt;/a&gt; an einen Prometheus Node Exporter angebunden.
Der Output sieht dann etwa so aus:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="gh"&gt;#&lt;/span&gt; HELP freifunk_node_online 1 if the Freifunk node is online, 0 otherwise
&lt;span class="gh"&gt;#&lt;/span&gt; TYPE freifunk_node_online gauge
&lt;span class="gh"&gt;#&lt;/span&gt; HELP ff3l_node_clients Number of clients connected to the node
&lt;span class="gh"&gt;#&lt;/span&gt; TYPE ff3l_node_clients gauge
freifunk_node_online{node=&amp;quot;&amp;lt;node0_id&amp;gt;&amp;quot;,hostname=&amp;quot;&amp;lt;node0_hostname&amp;gt;&amp;quot;} 1
freifunk_node_clients{node=&amp;quot;&amp;lt;node0_id&amp;gt;&amp;quot;,hostname=&amp;quot;&amp;lt;node0_hostname&amp;gt;&amp;quot;} 2
freifunk_node_online{node=&amp;quot;&amp;lt;node1_id&amp;gt;&amp;quot;,hostname=&amp;quot;&amp;lt;node1_hostname&amp;gt;&amp;quot;} 0
freifunk_node_clients{node=&amp;quot;&amp;lt;node1_id&amp;gt;&amp;quot;,hostname=&amp;quot;&amp;lt;node1_hostname&amp;gt;&amp;quot;} 0
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Das Ergebnis gibt es als fertiges Script auf &lt;a href="https://gitlab.com/snippets/1937620"&gt;Gitlab&lt;/a&gt;.&lt;/p&gt;</content><category term="hacks"/><category term="Freifunk"/><category term="Monitoring"/><category term="Prometheus"/></entry></feed>