<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>zli(1): blog</title>
  <subtitle>Notes on Linux, self-hosting, and the tooling around signal processing work.</subtitle>
  <link href="https://zhengnanli.gitlab.io/feed.xml" rel="self"/>
  <link href="https://zhengnanli.gitlab.io/blog/"/>
  <updated>2026-08-03T00:00:00.000Z</updated>
  <id>https://zhengnanli.gitlab.io/blog/</id>
  <author>
    <name>Zhengnan Li</name>
  </author>
  <entry>
    <title>Self-hosting Vaultwarden, and the one path that ruins it</title>
    <link href="https://zhengnanli.gitlab.io/blog/vaultwarden/"/>
    <updated>2026-08-03T00:00:00.000Z</updated>
    <published>2026-08-03T00:00:00.000Z</published>
    <id>https://zhengnanli.gitlab.io/blog/vaultwarden/</id>
    <summary>Deploying Vaultwarden behind nginx on a small VPS: closing signups properly, why disabling the web vault is worth the inconvenience, and the client-server version skew that produces a completely useless error message.</summary>
    <content type="html">&lt;p&gt;Vaultwarden is a Rust reimplementation of the Bitwarden server API, small enough
to run comfortably on a 2 vCPU box alongside other services. It inherits
Bitwarden&#39;s client-side encryption: the master password never reaches the server,
the vault key is derived on the device, and the server only ever stores ciphertext
plus a KDF-derived hash. A full server breach does not hand over your passwords.&lt;/p&gt;
&lt;p&gt;That property is what makes self-hosting defensible. It is also easy to
accidentally give it away, which is most of what this post is about.&lt;/p&gt;
&lt;h2&gt;The deployment&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;services:
  vaultwarden:
    image: vaultwarden/server:1.37.1
    container_name: vaultwarden
    restart: unless-stopped
    ports:
      # Loopback only; nginx terminates TLS and proxies to it.
      - &amp;quot;127.0.0.1:8082:80&amp;quot;
    volumes:
      - ./vaultwarden-data:/data
    environment:
      # Must match the public URL exactly or the clients break.
      - DOMAIN=https://vault.example.com
      - SIGNUPS_ALLOWED=false
      - SHOW_PASSWORD_HINT=false
      - WEB_VAULT_ENABLED=false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No &lt;code&gt;ADMIN_TOKEN&lt;/code&gt;, so the admin panel does not exist. One fewer credential to
leak on a box that is reachable from the internet.&lt;/p&gt;
&lt;p&gt;nginx in front needs two things beyond the usual proxy headers:
&lt;code&gt;client_max_body_size 128m&lt;/code&gt; for attachments and full exports, and WebSocket
upgrade headers, because &lt;code&gt;/notifications/hub&lt;/code&gt; is how clients learn about changes
made elsewhere. Without the upgrade headers, everything works except live sync,
which is a confusing thing to debug later.&lt;/p&gt;
&lt;p&gt;Do &lt;strong&gt;not&lt;/strong&gt; put HTTP basic auth in front of it. The browser extension and mobile
clients cannot answer that prompt.&lt;/p&gt;
&lt;h2&gt;Closing signups, and actually checking&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;SIGNUPS_ALLOWED=false&lt;/code&gt; is the setting. Verifying it is less obvious, because
there are three registration paths and they do not all report the same way.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;V=https://vault.example.com

curl -s -X POST &amp;quot;$V/identity/accounts/register&amp;quot; &#92;
  -H &#39;Content-Type: application/json&#39; &#92;
  --data &#39;{&amp;quot;email&amp;quot;:&amp;quot;probe@example.com&amp;quot;,&amp;quot;name&amp;quot;:&amp;quot;p&amp;quot;,&amp;quot;masterPasswordHash&amp;quot;:&amp;quot;x&amp;quot;,&amp;quot;key&amp;quot;:&amp;quot;x&amp;quot;,&amp;quot;kdf&amp;quot;:0,&amp;quot;kdfIterations&amp;quot;:600000}&#39;

curl -s -X POST &amp;quot;$V/identity/accounts/register/send-verification-email&amp;quot; &#92;
  -H &#39;Content-Type: application/json&#39; --data &#39;{&amp;quot;email&amp;quot;:&amp;quot;probe@example.com&amp;quot;,&amp;quot;name&amp;quot;:&amp;quot;p&amp;quot;}&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Both return &lt;code&gt;Registration not allowed or user already exists&lt;/code&gt;. The third,
&lt;code&gt;/identity/accounts/register/finish&lt;/code&gt;, returns &lt;code&gt;Registration is missing required parameters&lt;/code&gt; for a payload it cannot deserialise. That is a &lt;strong&gt;validation&lt;/strong&gt; error
raised before the signup check, so on its own it tells you nothing about whether
signups are open. Reading the source, it delegates to the same &lt;code&gt;register()&lt;/code&gt;
handler that enforces &lt;code&gt;CONFIG.is_signup_allowed(&amp;amp;email)&lt;/code&gt;, so it is covered.&lt;/p&gt;
&lt;p&gt;The check that settles it regardless of endpoint semantics:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sqlite3 /path/to/vaultwarden-data/db.sqlite3 &#39;SELECT email FROM users;&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One more wrinkle: the web vault UI keeps showing a &amp;quot;Create account&amp;quot; link even
with signups closed. It is cosmetic. The server refuses.&lt;/p&gt;
&lt;h2&gt;Turning off the web vault&lt;/h2&gt;
&lt;p&gt;This is the recommendation I would push hardest, and it is the one that costs you
something.&lt;/p&gt;
&lt;p&gt;The threat model of client-side encryption assumes the client is trustworthy. The
browser extension and desktop apps ship their own code; they never download it
from your server. The &lt;strong&gt;web vault does&lt;/strong&gt;. So if someone gets code execution on
your box, they can modify the JavaScript it serves and capture your master
password the next time you log in through a browser. That converts a server
compromise, which the encryption model is designed to survive, into total vault
compromise.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- WEB_VAULT_ENABLED=false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After recreating the container, the root path is gone but the API is untouched:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;GET /                                      404
GET /alive                                 200
GET /api/config                            200
POST /identity/accounts/prelogin/password  200
POST /identity/connect/token   (bad pw)     400
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A 404 on &lt;code&gt;/&lt;/code&gt; with a 200 on &lt;code&gt;/alive&lt;/code&gt; is the healthy state. Extensions and desktop
and mobile clients keep working exactly as before. What you lose is browser
access from a machine where you have not installed a client, which for most
people is rarer than it sounds.&lt;/p&gt;
&lt;h2&gt;The error message that tells you nothing&lt;/h2&gt;
&lt;p&gt;Here is the failure that cost me the most time, and the lesson generalises.&lt;/p&gt;
&lt;p&gt;The Firefox extension refused to log in to the self-hosted server with &amp;quot;an
unexpected error has occurred&amp;quot;. Nothing else. I checked the obvious things: the
certificate chain verified with return code 0, &lt;code&gt;/alive&lt;/code&gt; and &lt;code&gt;/api/config&lt;/code&gt; both
returned 200, &lt;code&gt;prelogin&lt;/code&gt; returned proper KDF parameters, and &lt;code&gt;connect/token&lt;/code&gt;
correctly rejected a deliberately wrong password. Every endpoint I tested by hand
behaved perfectly.&lt;/p&gt;
&lt;p&gt;The server log had the answer immediately:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;POST /identity/accounts/prelogin/password  =&amp;gt;  404 Not Found
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Current Bitwarden clients call &lt;code&gt;/identity/accounts/prelogin/password&lt;/code&gt; as the
first step of login. The version I had pinned did not implement that route. It
404s, and the client surfaces that as a generic error with no indication of which
request failed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Read the server log before touching anything else.&lt;/strong&gt; Client-side error strings
in this ecosystem are close to useless, and the server names the exact route.&lt;/p&gt;
&lt;p&gt;The deeper mistake was mine: I had pinned the image version out of habit, the way
you would pin a database to avoid an unwanted schema migration. But this is a
service whose clients update themselves continuously, from browser stores and app
stores you do not control. Pinning it far behind guarantees that eventually a
client speaks an API your server does not. For a self-hosted server with
auto-updating clients, staying current is the safer default.&lt;/p&gt;
&lt;p&gt;Since schema migrations are one-way, back up before upgrading:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sqlite3 vaultwarden-data/db.sqlite3 &amp;quot;.backup &#39;/tmp/vw.sqlite3&#39;&amp;quot;
tar czf /path/to/backups/vaultwarden-manual-$(date -u +%Y%m%dT%H%M%SZ).tar.gz &#92;
    -C /path/to vaultwarden-data
# then bump the tag, docker compose pull &amp;amp;&amp;amp; up -d, and check:
docker logs vaultwarden 2&amp;gt;&amp;amp;1 | grep -iE &#39;error|panic|migrat&#39;
sqlite3 vaultwarden-data/db.sqlite3 &#39;SELECT COUNT(*) FROM ciphers;&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Two things about backups&lt;/h2&gt;
&lt;p&gt;If you back this up, and you should, two details are easy to get wrong.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Use &lt;code&gt;sqlite3 .backup&lt;/code&gt;, not &lt;code&gt;cp&lt;/code&gt;.&lt;/strong&gt; The database runs in WAL mode. When I looked,
there was a live 247 KB &lt;code&gt;db.sqlite3-wal&lt;/code&gt; sitting next to the main file. Copying
just the file captures a torn state and silently loses committed data. &lt;code&gt;.backup&lt;/code&gt;
takes a proper read lock without stopping the service.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Include &lt;code&gt;rsa_key.pem&lt;/code&gt;.&lt;/strong&gt; It signs the JWTs. Restore without it and every client
is silently logged out, which looks like a much worse failure than it is.&lt;/p&gt;
&lt;p&gt;And encrypt the backup before it leaves the machine. The vault items are already
encrypted client-side, but the database also contains the master password hash,
which is offline-crackable. That should not sit unencrypted in someone else&#39;s
object storage.&lt;/p&gt;
&lt;h2&gt;Where it actually stands&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;signups&lt;/td&gt;
&lt;td&gt;closed, verified against all three endpoints&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;web vault&lt;/td&gt;
&lt;td&gt;off&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;admin panel&lt;/td&gt;
&lt;td&gt;disabled, no token set&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2FA&lt;/td&gt;
&lt;td&gt;TOTP enabled&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;KDF&lt;/td&gt;
&lt;td&gt;PBKDF2-SHA256, 600,000 iterations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;exposure&lt;/td&gt;
&lt;td&gt;loopback only, nginx with TLS 1.2/1.3 and HSTS&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The remaining upgrade is switching the KDF to Argon2id, a one-time client-side
change, since it is materially harder to attack on GPUs than PBKDF2. Worth
knowing that 2FA does nothing for that threat: an attacker cracking a stolen
database never touches your API, so only the master password&#39;s strength matters
there. 2FA protects the online path.&lt;/p&gt;
&lt;h2&gt;Is it safe?&lt;/h2&gt;
&lt;p&gt;Cryptographically, self-hosted and hosted Bitwarden are the same design. What
differs is who runs it. The hosted service has a security team, audits, DDoS
absorption and reliable backups.&lt;/p&gt;
&lt;p&gt;For most people hosted is safer &lt;em&gt;in practice&lt;/em&gt;, not because of the cryptography
but because the failure mode of self-hosting is operational: backups you never
took, and patches you never applied. Both of my problems above were exactly that.
Self-hosting is a reasonable choice if you own those two things honestly.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Metrics and a Homepage</title>
    <link href="https://zhengnanli.gitlab.io/blog/monitoring/"/>
    <updated>2026-08-03T00:00:00.000Z</updated>
    <published>2026-08-03T00:00:00.000Z</published>
    <id>https://zhengnanli.gitlab.io/blog/monitoring/</id>
    <summary>VictoriaMetrics instead of Prometheus, why host networking is the simpler choice for a loopback-only metrics stack, and validating a homepage dashboard config before it takes the service down.</summary>
    <content type="html">&lt;p&gt;Two additions to a small VPS that was already running a git forge, an RSS reader
and a password vault: a metrics stack for actual history, and a homepage for the
at-a-glance view. Combined footprint is around 250 MB resident, on a box with
3.7 GB total.&lt;/p&gt;
&lt;h2&gt;VictoriaMetrics instead of Prometheus&lt;/h2&gt;
&lt;p&gt;Same query language, same scrape config format, and Grafana treats it as a
Prometheus datasource. On this workload it sits at about 157 MB resident where
Prometheus would want considerably more, and retention is a single flag
(&lt;code&gt;-retentionPeriod=6&lt;/code&gt; for six months, which at a 30 second scrape interval of one
node is tens of megabytes).&lt;/p&gt;
&lt;p&gt;Nothing about the setup is VictoriaMetrics-specific. If you already know
Prometheus, you already know this.&lt;/p&gt;
&lt;h2&gt;Host networking, deliberately&lt;/h2&gt;
&lt;p&gt;All three components use &lt;code&gt;network_mode: host&lt;/code&gt; and bind their own loopback
addresses:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;  victoriametrics:
    image: victoriametrics/victoria-metrics:v1.112.0
    network_mode: host
    command:
      - &#39;-httpListenAddr=127.0.0.1:8428&#39;
      - &#39;-promscrape.config=/etc/prometheus.yml&#39;
      - &#39;-retentionPeriod=6&#39;

  node-exporter:
    image: prom/node-exporter:v1.9.1
    network_mode: host
    pid: host
    volumes:
      - /proc:/host/proc:ro
      - /sys:/host/sys:ro
      - /:/rootfs:ro
    command:
      - &#39;--web.listen-address=127.0.0.1:9100&#39;
      - &#39;--path.procfs=/host/proc&#39;
      - &#39;--path.sysfs=/host/sys&#39;
      - &#39;--path.rootfs=/rootfs&#39;

  grafana:
    image: grafana/grafana:11.6.1
    network_mode: host
    environment:
      - GF_SERVER_HTTP_ADDR=127.0.0.1
      - GF_SERVER_HTTP_PORT=3001
      - GF_SERVER_ROOT_URL=https://metrics.example.com
      - GF_USERS_ALLOW_SIGN_UP=false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The alternative, bridge networking with published ports, means VictoriaMetrics
cannot reach node_exporter on &lt;code&gt;127.0.0.1:9100&lt;/code&gt; and Grafana cannot reach
VictoriaMetrics. You end up putting them on a shared bridge and hardcoding
gateway addresses that change whenever a network is recreated. Host networking
with explicit loopback binds is less machinery and exposes nothing; nginx in
front is the only thing on a public port.&lt;/p&gt;
&lt;p&gt;The catch is that you &lt;strong&gt;must&lt;/strong&gt; set each listen address, because the defaults bind
&lt;code&gt;0.0.0.0&lt;/code&gt;. Host networking means a default-bound service is immediately public.&lt;/p&gt;
&lt;p&gt;Two smaller traps. &lt;code&gt;grafana-data&lt;/code&gt; has to be owned by uid &lt;code&gt;472&lt;/code&gt;, the user Grafana
runs as in the official image, or it cannot create its own sqlite database. And
despite &lt;code&gt;--path.rootfs=/rootfs&lt;/code&gt;, node_exporter still labels the root filesystem
&lt;code&gt;mountpoint=&amp;quot;/&amp;quot;&lt;/code&gt;, so queries use that, not &lt;code&gt;/rootfs&lt;/code&gt;. I wrote a dashboard panel
against &lt;code&gt;/rootfs&lt;/code&gt; first and got a silent no-data panel.&lt;/p&gt;
&lt;h2&gt;Provisioned, not clicked&lt;/h2&gt;
&lt;p&gt;Datasource and dashboards as files, so the whole thing is reproducible:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# provisioning/datasources/vm.yml
apiVersion: 1
datasources:
  - name: VictoriaMetrics
    type: prometheus
    access: proxy
    uid: victoriametrics
    url: http://127.0.0.1:8428
    isDefault: true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every panel in the dashboard JSON references
&lt;code&gt;{&amp;quot;type&amp;quot;: &amp;quot;prometheus&amp;quot;, &amp;quot;uid&amp;quot;: &amp;quot;victoriametrics&amp;quot;}&lt;/code&gt;. If that &lt;code&gt;uid&lt;/code&gt; does not match
the provisioned datasource, panels render empty with no error at all, which is a
miserable thing to debug. It is the first thing to check when a provisioned
dashboard looks broken.&lt;/p&gt;
&lt;h2&gt;Change the default password before the cert exists&lt;/h2&gt;
&lt;p&gt;Grafana starts on &lt;code&gt;admin/admin&lt;/code&gt;. Worth thinking about the ordering here: issuing a
TLS certificate publishes the hostname to public Certificate Transparency logs
within seconds, and scanners read those logs continuously. In my case automated
probes arrived at brand-new hostnames within minutes of the certificate being
issued.&lt;/p&gt;
&lt;p&gt;So anything with a first-run setup state is exposed from the moment the cert
exists, not from the moment you tell someone the URL. That applies to Grafana&#39;s
default credentials and, more sharply, to any service where the &lt;em&gt;first visitor
becomes the administrator&lt;/em&gt;, which is how several self-hosted dashboards
bootstrap. Set credentials before or immediately after issuing the certificate.&lt;/p&gt;
&lt;p&gt;One footgun if you script it: &lt;code&gt;GF_SECURITY_ADMIN_PASSWORD&lt;/code&gt; is applied on container
start, so a stale value left in your compose file silently resets the password
back to it on the next recreate. Either manage the password entirely through that
variable, or do not set it at all.&lt;/p&gt;
&lt;h2&gt;The homepage layer&lt;/h2&gt;
&lt;p&gt;Separately from Grafana, a &lt;a href=&quot;https://github.com/glanceapp/glance&quot;&gt;Glance&lt;/a&gt;
instance for the things you want on one page: clock, service up/down monitors,
RSS, repository releases, bookmarks, host stats. About 8 MB resident, which makes
it hard to argue with.&lt;/p&gt;
&lt;p&gt;Two things I would tell anyone setting it up.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Set &lt;code&gt;server.host&lt;/code&gt; to &lt;code&gt;0.0.0.0&lt;/code&gt; inside the container.&lt;/strong&gt; The default of
&lt;code&gt;localhost&lt;/code&gt; binds the container&#39;s own loopback, which makes a published port
unreachable. Docker does the loopback restriction from the outside; the app should
not.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Validate the config before restarting.&lt;/strong&gt; It refuses to start on a bad config,
so a typo takes the dashboard down. Validating against the config directory
without touching the running container costs nothing:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker run --rm -v /path/to/config:/app/config:ro glanceapp/glance config:validate
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Silence means valid. It reports one error at a time with a line number. This
caught two mistakes for me before they mattered: an indentation problem, and two
widget types that appear in the current documentation but do not exist in the
released binary. When a widget name is rejected, trust the binary over the docs:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker run --rm --entrypoint sh glanceapp/glance -c &#92;
  &#39;strings &amp;quot;$(command -v glance)&amp;quot; | grep -oE &amp;quot;&#92;b(rss|monitor|todo|calendar-legacy)&#92;b&amp;quot; | sort -u&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Also set &lt;code&gt;slug:&lt;/code&gt; explicitly on every page. Auto-derived slugs from titles
containing punctuation do not produce the URL you would guess, and the page 404s
while the root still works.&lt;/p&gt;
&lt;h2&gt;Almost every widget needs outbound network&lt;/h2&gt;
&lt;p&gt;Obvious in hindsight, and worth a sentence because it made the dashboard look
broken for reasons unrelated to the dashboard. Glance is mostly a feed
aggregator: RSS, releases, monitors, weather, all of it is outbound HTTP from the
container. On this host, container egress was silently blackholed by an unrelated
firewall problem, so the dashboard rendered clocks and bookmarks and nothing
else.&lt;/p&gt;
&lt;p&gt;If widgets are uniformly empty, test the container&#39;s network before reading any
config:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker exec glance sh -c &#39;wget -q -T 8 -O /dev/null https://api.github.com &amp;amp;&amp;amp; echo OK || echo FAIL&#39;
&lt;/code&gt;&lt;/pre&gt;
</content>
  </entry>
  <entry>
    <title>Encrypted offsite backups that verify themselves</title>
    <link href="https://zhengnanli.gitlab.io/blog/backups/"/>
    <updated>2026-08-03T00:00:00.000Z</updated>
    <published>2026-08-03T00:00:00.000Z</published>
    <id>https://zhengnanli.gitlab.io/blog/backups/</id>
    <summary>Nightly age-encrypted backups from a small VPS to consumer cloud storage: why two different mechanisms for two shapes of data, why every run restores its own output, and the circular dependency that makes an encrypted backup useless.</summary>
    <content type="html">&lt;p&gt;I had been running a password vault and a git mirror forge on a VPS for a while
with no backups at all. Not a partial backup, not an untested one. None. The disk
was a single 40 GB volume and the only copy of anything lived on it.&lt;/p&gt;
&lt;p&gt;This is the more likely way to lose data than any attacker, and it is worth
stating plainly because the failure mode of self-hosting is almost never a breach.
It is a disk, or a fat-fingered &lt;code&gt;rm&lt;/code&gt;, or a migration that goes sideways.&lt;/p&gt;
&lt;p&gt;What follows is what I built. Two jobs, two mechanisms, and a couple of design
decisions that are not obvious.&lt;/p&gt;
&lt;h2&gt;Encrypt before it leaves the machine&lt;/h2&gt;
&lt;p&gt;The vault&#39;s items are already encrypted client-side. But its database also
contains the master password hash, which is offline-crackable. That should not sit
in third-party storage in the clear, no matter how much you trust the provider.&lt;/p&gt;
&lt;p&gt;I used &lt;a href=&quot;https://github.com/FiloSottile/age&quot;&gt;age&lt;/a&gt;, which is a keypair rather than a
passphrase:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo age-keygen -o /etc/backup/age-identity.key
sudo chmod 600 /etc/backup/age-identity.key
sudo sh -c &#39;age-keygen -y /etc/backup/age-identity.key &amp;gt; /etc/backup/age-recipient.txt&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The key lives on the box, and that is a deliberate limit worth being explicit
about. It does &lt;strong&gt;not&lt;/strong&gt; protect against a compromise of the box, which can read the
live database anyway. It protects against exposure at the storage provider. Since
an attacker with root already has the plaintext, keeping the key locally costs
nothing against that threat and lets the jobs run unattended.&lt;/p&gt;
&lt;h2&gt;Two mechanisms, because the data has two shapes&lt;/h2&gt;
&lt;p&gt;The vault database is a few hundred kilobytes. The git mirror tree is 1.7 GB
across 71 repositories, of which roughly 200 files change on any given day.&lt;/p&gt;
&lt;p&gt;Shipping a 1.7 GB encrypted tarball every night to protect 200 changed files is
absurd. But there is no good way to incrementally update an encrypted archive,
either. So:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Small things&lt;/strong&gt; ship as whole &lt;code&gt;age&lt;/code&gt;-encrypted archives, one per run, with dated
retention.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The git tree&lt;/strong&gt; goes through &lt;code&gt;rclone sync&lt;/code&gt; to an encrypted remote. Git objects
are immutable and content-addressed, so after the first push each run transfers
only genuinely new objects. Filenames are encrypted too, since the archive is
not a single opaque blob.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The tradeoff to understand: &lt;code&gt;rclone sync&lt;/code&gt; is a &lt;strong&gt;mirror, not history&lt;/strong&gt;. A repo
deleted locally is deleted remotely on the next run. That is acceptable here
because the repos are themselves mirrors of upstreams, but if you need
point-in-time recovery, use &lt;code&gt;--backup-dir&lt;/code&gt; or a real snapshotting tool.&lt;/p&gt;
&lt;h2&gt;Use the database&#39;s own backup command&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sqlite3 &amp;quot;$LIVE_DB&amp;quot; &amp;quot;.backup &#39;$STAGING/db.sqlite3&#39;&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Not &lt;code&gt;cp&lt;/code&gt;. Both databases here run in WAL mode, and when I looked there was a live
247 KB &lt;code&gt;-wal&lt;/code&gt; file next to the main one. Copying just the file captures a torn
state and silently loses committed transactions. &lt;code&gt;.backup&lt;/code&gt; takes a proper read
lock without stopping the service.&lt;/p&gt;
&lt;p&gt;The same care applies on the way back: when restoring over a live database,
delete the stale &lt;code&gt;-wal&lt;/code&gt; and &lt;code&gt;-shm&lt;/code&gt; files, because they belong to the database you
just replaced.&lt;/p&gt;
&lt;h2&gt;Every run restores its own output&lt;/h2&gt;
&lt;p&gt;This is the part I would keep if I threw away everything else.&lt;/p&gt;
&lt;p&gt;A backup that has never been restored is not a backup, it is a guess. So the job
does not finish by uploading. It finishes by decrypting what it just wrote,
unpacking it, and checking the result:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;age -d -i &amp;quot;$IDENTITY&amp;quot; &amp;quot;$ARCHIVE&amp;quot; | tar xzf - -C &amp;quot;$VERIFY&amp;quot; &#92;
  || die &amp;quot;verification failed: could not decrypt and untar&amp;quot;

INTEG=$(sqlite3 &amp;quot;$VERIFY/db.sqlite3&amp;quot; &#39;PRAGMA integrity_check;&#39;)
[ &amp;quot;$INTEG&amp;quot; = &amp;quot;ok&amp;quot; ] || die &amp;quot;verification failed: integrity_check said &#39;$INTEG&#39;&amp;quot;

COUNT=$(sqlite3 &amp;quot;$VERIFY/db.sqlite3&amp;quot; &#39;SELECT COUNT(*) FROM items;&#39;)
[ &amp;quot;$COUNT&amp;quot; = &amp;quot;$LIVE_COUNT&amp;quot; ] || die &amp;quot;verification failed: $COUNT in backup vs $LIVE_COUNT live&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three separate claims get checked: it decrypts, the database is structurally
intact, and it contains the same number of rows as the live one. If any fails, the
job exits non-zero and &lt;strong&gt;does not upload&lt;/strong&gt;, so a bad archive never displaces a
good one in the retention window.&lt;/p&gt;
&lt;p&gt;The same paranoia applies to the upload. &lt;code&gt;rclone copy&lt;/code&gt; succeeding is not evidence
the object is there:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;LOCAL=$(stat -c%s &amp;quot;$ARCHIVE&amp;quot;)
REMOTE=$(rclone size &amp;quot;$REMOTE_PATH/$(basename &amp;quot;$ARCHIVE&amp;quot;)&amp;quot; --json &#92;
         | sed -n &#39;s/.*&amp;quot;bytes&amp;quot;:&#92;([0-9]*&#92;).*/&#92;1/p&#39;)
[ &amp;quot;$REMOTE&amp;quot; = &amp;quot;$LOCAL&amp;quot; ] || die &amp;quot;size mismatch (local $LOCAL, remote ${REMOTE:-none})&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It also refuses to ship an obviously wrong snapshot at all:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;[ &amp;quot;$COUNT&amp;quot; -gt 0 ] || die &amp;quot;snapshot has zero items, refusing to ship it&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;An empty backup that overwrites a good one is worse than a failed job.&lt;/p&gt;
&lt;h2&gt;Two rclone details that cost me time&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;rclone config create&lt;/code&gt; hangs on OAuth backends.&lt;/strong&gt; Supplying a token on the
command line does not stop it trying to run the interactive browser flow, and with
stdin not a terminal it blocks forever. Write the config file by hand:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ini&quot;&gt;[remote]
type = &amp;lt;provider&amp;gt;
hostname = &amp;lt;region-specific api host&amp;gt;
token = {&amp;quot;access_token&amp;quot;:&amp;quot;...&amp;quot;,&amp;quot;token_type&amp;quot;:&amp;quot;bearer&amp;quot;,&amp;quot;expiry&amp;quot;:&amp;quot;0001-01-01T00:00:00Z&amp;quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Providers with no token expiry can be wrapped like that from a bare access token.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Region matters and is not auto-detected when you supply a token by hand.&lt;/strong&gt;
rclone normally learns the correct API host during its own OAuth dance. Skip that
and it uses the default host, which fails to authenticate for accounts in another
region, with an error that looks exactly like a bad token. The provider&#39;s docs for
the backend usually say which option to set.&lt;/p&gt;
&lt;p&gt;Also worth knowing: &lt;code&gt;rclone obscure&lt;/code&gt; is &lt;strong&gt;reversible obfuscation, not
encryption&lt;/strong&gt;. A crypt password in &lt;code&gt;rclone.conf&lt;/code&gt; is readable by anyone with root.
Same trade-off as the age key, and worth stating rather than assuming.&lt;/p&gt;
&lt;p&gt;And prefer &lt;code&gt;rclone sync --checksum&lt;/code&gt; for a git tree. The default mtime-and-size
comparison misses repacks that rewrite a file to the same size, and consumer
storage often has coarse mtime granularity.&lt;/p&gt;
&lt;h2&gt;The circular dependency&lt;/h2&gt;
&lt;p&gt;This is the failure mode that turns a working backup system into encrypted noise,
and it is easy to walk into.&lt;/p&gt;
&lt;p&gt;The obvious place to store the age key is a password manager. But if the password
manager is one of the things being backed up, then recovering the backup requires
the key, which requires the password manager, which requires the backup. Circular.&lt;/p&gt;
&lt;p&gt;Mine is subtler and I nearly missed it. The key went into &lt;code&gt;pass&lt;/code&gt;, which is fine,
because &lt;code&gt;pass&lt;/code&gt; lives on my laptop and pushes to a git host, both independent of the
VPS. But that same &lt;code&gt;pass&lt;/code&gt; repo is &lt;em&gt;mirrored&lt;/em&gt; onto the forge, and the forge is
backed up to the cloud under the key stored inside it. So the cloud copy of &lt;code&gt;pass&lt;/code&gt;
is not a recovery route for the key, even though it looks like one.&lt;/p&gt;
&lt;p&gt;Two rules that fall out of this:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Write down which copies of a secret are genuinely independent of the thing it
decrypts. &amp;quot;It&#39;s in three places&amp;quot; means nothing if two of them are downstream
of the encrypted backup.&lt;/li&gt;
&lt;li&gt;Keep one copy on a medium that has no dependency on any of it. An age key is
184 bytes over three lines. It fits on a piece of paper.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;There is a hardware version of the same trap: if your password store is
GPG-encrypted to a key on a hardware token, then losing the token and the laptop
together leaves the store unreadable, and the backups undecryptable with it.&lt;/p&gt;
&lt;h2&gt;Shape of the result&lt;/h2&gt;
&lt;p&gt;Two systemd timers, half an hour apart so they do not contend for two vCPUs and
one uplink, both &lt;code&gt;Persistent=true&lt;/code&gt; so a missed run catches up after a reboot.
Local retention of 14 days, remote 90, with the git tree mirrored rather than
dated. First full push of 1.7 GB took about 17 minutes; nightly runs move the
couple of hundred files that actually changed.&lt;/p&gt;
&lt;p&gt;The uncomfortable thing I would flag to anyone doing this: my scripts live in
&lt;code&gt;/usr/local/bin&lt;/code&gt; and my configs in &lt;code&gt;/etc&lt;/code&gt;, and &lt;strong&gt;neither is backed up&lt;/strong&gt;. The data
is safe and the machinery that protects it is not. For now the mitigation is that
both scripts are reproduced in full in my notes, which is a real answer but not a
good one. Backing up &lt;code&gt;/etc&lt;/code&gt; is the obvious next job.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>PGP and SSH keys on a YubiKey</title>
    <link href="https://zhengnanli.gitlab.io/blog/yubikey/"/>
    <updated>2026-08-01T00:00:00.000Z</updated>
    <published>2026-08-01T00:00:00.000Z</published>
    <id>https://zhengnanli.gitlab.io/blog/yubikey/</id>
    <summary>Moving a PGP key onto a YubiKey, restoring it on a second machine, wiring pass-git-helper into git, and generating FIDO2-backed SSH keys. Including which of it GitHub and GitLab refuse to support.</summary>
    <content type="html">&lt;p&gt;Two separate things that both end up on the same piece of hardware: a PGP
keypair whose private half lives on the card, and FIDO2-backed SSH keys. They
solve different problems and are worth keeping straight.&lt;/p&gt;
&lt;h2&gt;Generating and moving a PGP key&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;gpg --expert --full-gen-key
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Export everything before it goes anywhere near the card. &lt;code&gt;keytocard&lt;/code&gt; is a
&lt;strong&gt;move&lt;/strong&gt;, not a copy: it writes the key to the card and removes the private
half from your keyring. Without a backup at this point the key is gone the
moment the YubiKey is lost.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;gpg --export-secret-key --armor $KEYID &amp;gt; privkey.armor
gpg --export-secret-key $KEYID | paperkey &amp;gt; privkey.paperkey
gpg --export $KEYID &amp;gt; pubkey.armor
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;paperkey&lt;/code&gt; strips out everything reconstructible from the public key and leaves
only the secret bits, which is what makes a printed copy a reasonable size.
Store the armored copy somewhere offline.&lt;/p&gt;
&lt;p&gt;Then move it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;gpg --edit-key $KEYID
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and &lt;code&gt;keytocard&lt;/code&gt;. Select the key first by entering &lt;code&gt;key 1&lt;/code&gt;, otherwise the
command operates on the primary key rather than the subkey you meant.&lt;/p&gt;
&lt;h2&gt;Restoring on another machine&lt;/h2&gt;
&lt;p&gt;The card holds the private key, but a fresh machine still has no idea the
public key exists. Fetch it from a keyserver:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;gpg --keyserver hkps://keys.openpgp.org --search-keys &amp;quot;you@example.com&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then pull the card&#39;s public key stubs, which tell GnuPG that the private key
lives on hardware:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;gpg --edit-card
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and &lt;code&gt;fetch&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Finally, trust it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;gpg --edit-key $KEYID
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and &lt;code&gt;trust&lt;/code&gt;. Note that this is &lt;code&gt;--edit-key&lt;/code&gt;, not &lt;code&gt;--edit-card&lt;/code&gt;. Getting these
two confused is easy and the error messages do not point at the mistake.&lt;/p&gt;
&lt;h2&gt;git credentials through pass&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;pass-git-helper&lt;/code&gt; lets git pull credentials out of a &lt;code&gt;pass&lt;/code&gt; store, which is
itself encrypted to the PGP key on the card:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;yay -S pass-git-helper
git config --global credential.helper /usr/bin/pass-git-helper
git config credential.useHttpPath true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Create &lt;code&gt;~/.config/pass-git-helper/git-pass-mapping.ini&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ini&quot;&gt;[github.com]
target=github.com/&amp;lt;username&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;credential.useHttpPath true&lt;/code&gt; matters if you keep per-repository credentials,
since without it every host collapses to a single entry.&lt;/p&gt;
&lt;h2&gt;FIDO2-backed SSH keys&lt;/h2&gt;
&lt;p&gt;Separate mechanism, same token. OpenSSH 8.2 added the &lt;code&gt;ed25519-sk&lt;/code&gt; and
&lt;code&gt;ecdsa-sk&lt;/code&gt; key types, where the private key is bound to the hardware token and
useless without it. &lt;code&gt;libfido2&lt;/code&gt; is required.&lt;/p&gt;
&lt;p&gt;Both ends need to understand the key type, so an old server will reject these
outright.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ssh-keygen -t ed25519-sk
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You will be asked for the PIN and a touch to confirm generation, and normally a
touch on every connection afterwards.&lt;/p&gt;
&lt;p&gt;Point your config at the key explicitly:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Host SERVER1
    IdentitiesOnly yes
    IdentityFile ~/.ssh/id_ed25519_sk
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;IdentitiesOnly yes&lt;/code&gt; is worth setting. Without it, ssh offers every key it can
find, which on a token means a touch prompt for keys you did not intend to use.&lt;/p&gt;
&lt;h3&gt;Skipping the touch requirement&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ssh-keygen -O no-touch-required -t ed25519-sk
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three caveats, in increasing order of annoyance:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Not every token supports it. On a YubiKey, &lt;code&gt;ed25519-sk&lt;/code&gt; needs firmware 5.2.3
or newer.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;sshd&lt;/code&gt; rejects &lt;code&gt;no-touch-required&lt;/code&gt; keys by default. Allow it per key in
&lt;code&gt;authorized_keys&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;no-touch-required sk-ssh-ed25519@openssh.com AAAAInN... user@example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;or system-wide in &lt;code&gt;/etc/ssh/sshd_config&lt;/code&gt; with &lt;code&gt;PubkeyAuthOptions none&lt;/code&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;GitHub and GitLab do not support &lt;code&gt;no-touch-required&lt;/code&gt; at all.&lt;/strong&gt; If these
keys are for pushing to a forge, you are touching the token on every push
regardless of what you generated.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;An &lt;code&gt;ecdsa-sk&lt;/code&gt; keypair works the same way, with the usual reasons to prefer
ed25519 over ECDSA still applying.&lt;/p&gt;
&lt;p&gt;Reference: the Arch wiki&#39;s &lt;a href=&quot;https://wiki.archlinux.org/title/SSH_keys#FIDO/U2F&quot;&gt;SSH keys&lt;/a&gt;
page and Yubico&#39;s &lt;a href=&quot;https://developers.yubico.com/PGP/Importing_keys.html&quot;&gt;importing keys&lt;/a&gt;
guide.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>eSIM and WWAN on a ThinkPad P14s under Arch</title>
    <link href="https://zhengnanli.gitlab.io/blog/esim/"/>
    <updated>2026-08-01T00:00:00.000Z</updated>
    <published>2026-08-01T00:00:00.000Z</published>
    <id>https://zhengnanli.gitlab.io/blog/esim/</id>
    <summary>Getting the Quectel RM520N-GL 5G modem, FCC unlock, and an eSIM profile working on Arch: firmware, lpac, and the AT commands in between. Ends with the one part that still does not work.</summary>
    <content type="html">&lt;p&gt;Every piece of this is documented somewhere, and almost none of it is
documented together. This is the whole path from a dead WWAN card to an eSIM
profile installed and enabled on a ThinkPad P14s Gen 5 AMD running Arch, plus
the failure modes that cost the most time.&lt;/p&gt;
&lt;p&gt;Fair warning up front: the profile downloads and enables, but the modem still
does not attach to the network. The open problem is at the bottom, unresolved.
Everything above it is verified working.&lt;/p&gt;
&lt;h2&gt;Hardware&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Laptop&lt;/td&gt;
&lt;td&gt;ThinkPad P14s Gen 5 AMD&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Modem&lt;/td&gt;
&lt;td&gt;Quectel RM520N-GL (5G, Qualcomm)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;USB ID&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2c7c:0801&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Firmware&lt;/td&gt;
&lt;td&gt;&lt;code&gt;RM520NGLAAR03A03M4G&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SIM slots&lt;/td&gt;
&lt;td&gt;1 = physical nano-SIM, 2 = embedded eSIM (eUICC)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;eUICC free memory&lt;/td&gt;
&lt;td&gt;~411 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AT port&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/dev/wwan0at0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MBIM port&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/dev/wwan0mbim0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The two device nodes matter. &lt;code&gt;lpac&lt;/code&gt; talks to the eUICC over the AT port, while
radio state is set over MBIM. Reaching for the wrong one produces errors that
look like hardware faults.&lt;/p&gt;
&lt;h2&gt;1. Firmware&lt;/h2&gt;
&lt;p&gt;Firmware comes through &lt;code&gt;fwupdmgr&lt;/code&gt;, but current &lt;code&gt;fwupd&lt;/code&gt; will not do it. You need
to downgrade to 1.9.x from the &lt;a href=&quot;https://archive.archlinux.org/packages/f/fwupd/&quot;&gt;Arch Archive&lt;/a&gt;.
Stop ModemManager and unload &lt;code&gt;libmbim&lt;/code&gt;/&lt;code&gt;libqmi&lt;/code&gt; first, or the update fails
holding a busy device.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;fwupdmgr refresh --force
fwupdmgr get-devices
fwupdmgr update
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Confirm the modem still answers afterwards:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;minicom -D /dev/wwan0at0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Type &lt;code&gt;AT&lt;/code&gt;, expect &lt;code&gt;OK&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;2. FCC unlock&lt;/h2&gt;
&lt;p&gt;Lenovo ships the modem FCC-locked. The radio stays off until an unlock runs.
ModemManager has the script, it just is not wired up:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ln -s /usr/share/ModemManager/fcc-unlock.available.d/1eac &#92;
           /etc/ModemManager/fcc-unlock.d/1eac:1007
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the automatic unlock does not take, force the radio on directly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;mbimcli --device-open-proxy --device=&amp;quot;/dev/wwan0mbim0&amp;quot; &#92;
        --quectel-set-radio-state=on
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There is also an AUR package that handles this as a service:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;yay -S lenovo-wwan-unlock
sudo systemctl enable --now lenovo-wwan-unlock
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. Switch to the eSIM slot&lt;/h2&gt;
&lt;p&gt;The eUICC is slot 2. Stop ModemManager before touching the AT port, otherwise
the two fight over it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo systemctl stop ModemManager
sudo minicom -D /dev/wwan0at0
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;AT+QUIMSLOT=2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;AT+QUIMSLOT?&lt;/code&gt; reports the current slot.&lt;/p&gt;
&lt;h2&gt;4. Install lpac&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;lpac&lt;/code&gt; is the eSIM profile manager, the LPA in the specification&#39;s terms.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;paru -S lpac-git
sudo pacman -S pcsclite ccid
sudo systemctl enable --now pcscd.socket
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;The gotcha that wasted the most time.&lt;/strong&gt; The environment variables must be
passed through &lt;code&gt;sudo&lt;/code&gt;, on the same side as the command:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo LPAC_APDU=at LPAC_APDU_AT_DEVICE=/dev/wwan0at0 lpac chip info
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Setting them before &lt;code&gt;sudo&lt;/code&gt; means &lt;code&gt;lpac&lt;/code&gt; never sees them, falls back to the
PC/SC backend, and fails with &lt;code&gt;SCardEstablishContext&lt;/code&gt;, which looks like a
smartcard daemon problem and is not one.&lt;/p&gt;
&lt;p&gt;Also note the older names &lt;code&gt;AT_DEBUG&lt;/code&gt; and &lt;code&gt;AT_DEVICE&lt;/code&gt; are deprecated. Use
&lt;code&gt;LPAC_APDU_AT_DEBUG&lt;/code&gt; and &lt;code&gt;LPAC_APDU_AT_DEVICE&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;5. Download a profile&lt;/h2&gt;
&lt;p&gt;An eSIM QR code encodes &lt;code&gt;LPA:1$&amp;lt;SMDP_ADDRESS&amp;gt;$&amp;lt;ACTIVATION_CODE&amp;gt;&lt;/code&gt;. Split it on
the &lt;code&gt;$&lt;/code&gt; and feed the halves in:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo LPAC_APDU=at LPAC_APDU_AT_DEVICE=/dev/wwan0at0 &#92;
     lpac profile download -s &amp;lt;SMDP_ADDRESS&amp;gt; -m &amp;quot;&amp;lt;ACTIVATION_CODE&amp;gt;&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This needs working internet over some other interface, Wi-Fi being the obvious
one, since the download happens over IP and not over the cellular link you are
trying to provision.&lt;/p&gt;
&lt;h2&gt;6. Enable it&lt;/h2&gt;
&lt;p&gt;Downloading is not enabling. A downloaded but disabled profile reports as
&lt;code&gt;esim-without-profiles&lt;/code&gt;, which reads like the download failed when it did not:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo LPAC_APDU=at LPAC_APDU_AT_DEVICE=/dev/wwan0at0 &#92;
     lpac profile enable &amp;lt;ICCID&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then reset the modem from minicom so it re-reads the eUICC:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;AT+CFUN=1,1
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;7. Connect&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo systemctl start ModemManager
mmcli -m 0 --enable
nmcli connection add type gsm ifname &amp;quot;*&amp;quot; con-name &amp;lt;name&amp;gt; apn &amp;lt;apn&amp;gt;
nmcli connection up &amp;lt;name&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Known issues&lt;/h2&gt;
&lt;p&gt;Collected from the actual attempts, in rough order of how much time each cost:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;SCardEstablishContext&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;LPAC_APDU=at&lt;/code&gt; did not reach &lt;code&gt;lpac&lt;/code&gt; through
&lt;code&gt;sudo&lt;/code&gt;. See above.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;esim-without-profiles&lt;/code&gt; after a successful download&lt;/strong&gt;: the profile is
there but disabled. &lt;code&gt;lpac profile enable&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;lpac&lt;/code&gt; hangs&lt;/strong&gt;: the AT port can lock up during long operations. Reboot or
rebind the USB device, then check state with &lt;code&gt;lpac profile list&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;PhoneFailure&lt;/code&gt; on a network scan&lt;/strong&gt;: radio is off. Re-run the &lt;code&gt;mbimcli&lt;/code&gt;
radio-state command and retry.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;EID mismatch on download&lt;/strong&gt;: the profile was bound to a different device&#39;s
eUICC. There is no fix on the Linux side; the carrier has to re-provision
against the right EID.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Activation code already consumed&lt;/strong&gt;: once a profile has been downloaded to
any device, the code is spent. It cannot be reused, including after a failed
install on the same machine.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Profiles do not transfer between devices&lt;/strong&gt;: once activated on one eUICC the
binding is permanent. Confirmed with the provider. Moving to another laptop
means buying another profile, which is worth knowing before you buy the
first one.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;General modem instability&lt;/strong&gt;: the RM520N-GL resets itself fairly often, and
the radio-state command frequently needs repeating afterwards.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;The part that does not work&lt;/h2&gt;
&lt;p&gt;The profile is installed and enabled. The modem does not attach.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;mmcli -m 0 --3gpp-scan&lt;/code&gt; fails with &lt;code&gt;PhoneFailure&lt;/code&gt; even directly after a
successful radio-state-on, and the correct APN for the provider is still
unknown; the plausible candidates each fail the same way, so the scan failure
is probably the real blocker rather than the APN.&lt;/p&gt;
&lt;p&gt;Two open threads: whether the repeated self-resets are a firmware problem that
a newer &lt;code&gt;RM520NGL&lt;/code&gt; build fixes, and whether the profile needs a carrier-side
activation step that was never performed. I will update this post when the
link comes up.&lt;/p&gt;
&lt;h2&gt;Command reference&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;List modems&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mmcli -L&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Modem details&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mmcli -m 0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network scan&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mmcli -m 0 --3gpp-scan&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Radio on&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mbimcli --device-open-proxy --device=&amp;quot;/dev/wwan0mbim0&amp;quot; --quectel-set-radio-state=on&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;eUICC info&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sudo LPAC_APDU=at LPAC_APDU_AT_DEVICE=/dev/wwan0at0 lpac chip info&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;List profiles&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sudo LPAC_APDU=at LPAC_APDU_AT_DEVICE=/dev/wwan0at0 lpac profile list&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enable profile&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sudo LPAC_APDU=at LPAC_APDU_AT_DEVICE=/dev/wwan0at0 lpac profile enable &amp;lt;ICCID&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Modem reset&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AT+CFUN=1,1&lt;/code&gt; (minicom)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Check SIM slot&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AT+QUIMSLOT?&lt;/code&gt; (minicom)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Switch to eSIM&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AT+QUIMSLOT=2&lt;/code&gt; (minicom)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Your own IMEI, EID, and ICCID appear throughout &lt;code&gt;lpac&lt;/code&gt; and &lt;code&gt;mmcli&lt;/code&gt; output.
They identify your hardware and your SIM subscription, so keep them out of
anything you paste into a forum thread or an issue tracker.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Linux tips and tricks</title>
    <link href="https://zhengnanli.gitlab.io/blog/arch-tips/"/>
    <updated>2026-08-01T00:00:00.000Z</updated>
    <published>2026-08-01T00:00:00.000Z</published>
    <id>https://zhengnanli.gitlab.io/blog/arch-tips/</id>
    <summary>An Arch grab bag worth writing down: a three-branch dotfiles model with skip-worktree, dnsmasq under NetworkManager, keyd, amd_pstate, and the small fixes that are annoying to rediscover.</summary>
    <content type="html">&lt;p&gt;Accumulated notes that are individually too small to be posts and collectively
annoying to rediscover. Arch on a desktop and a ThinkPad, Hyprland, fish.&lt;/p&gt;
&lt;h2&gt;Dotfiles across two machines&lt;/h2&gt;
&lt;p&gt;The interesting problem is not &amp;quot;put configs in git,&amp;quot; it is keeping two machines
mostly identical without either one&#39;s quirks leaking into the other. Three
long-lived branches:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Branch&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;base&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;shared, machine-neutral config, byte-identical across machines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;master&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;desktop, &lt;code&gt;base&lt;/code&gt; plus desktop bits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;thinkpad&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;laptop, &lt;code&gt;base&lt;/code&gt; plus laptop bits&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;A shared change is committed on &lt;code&gt;base&lt;/code&gt; and propagated:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git switch master   &amp;amp;&amp;amp; git merge base
git switch thinkpad &amp;amp;&amp;amp; git merge base
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A machine-specific change is committed on that machine&#39;s branch only. Catching
a shared change on a machine branch means cherry-picking it back to &lt;code&gt;base&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Deployment is &lt;a href=&quot;https://www.gnu.org/software/stow/&quot;&gt;GNU Stow&lt;/a&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;stow -t ~/.config -S &amp;lt;module&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Per-machine files are not tracked at all.&lt;/strong&gt; They are gitignored and seeded
from a tracked &lt;code&gt;*.example&lt;/code&gt; template, which is what keeps &lt;code&gt;base&lt;/code&gt; genuinely
identical everywhere rather than merely similar:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cp hypr/hypr/monitors.conf.example      hypr/hypr/monitors.conf
cp kitty/kitty/kitty-local.conf.example kitty/kitty/kitty-local.conf
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;The generated-file problem&lt;/h3&gt;
&lt;p&gt;Some tracked files get rewritten at runtime. Fish toggles a helix theme file
between light and dark, for instance. You want the committed copy to exist so a
fresh clone works, but you do not want the local churn showing up in every
&lt;code&gt;git status&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;.gitignore&lt;/code&gt; cannot do this. It only affects untracked files, and these are
tracked on purpose. The tool is &lt;code&gt;skip-worktree&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git update-index --skip-worktree &amp;lt;file&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To update the committed baseline later, reverse it, commit, and re-apply:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git update-index --no-skip-worktree &amp;lt;file&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Worth keeping this in a script, because the list grows and reapplying it is a
per-clone step that is easy to forget.&lt;/p&gt;
&lt;h2&gt;Networking&lt;/h2&gt;
&lt;h3&gt;dnsmasq under NetworkManager&lt;/h3&gt;
&lt;p&gt;Local caching resolver, with NetworkManager managing it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ini&quot;&gt;# /etc/NetworkManager/conf.d/dns.conf
[main]
dns=dnsmasq
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo nmcli general reload
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;NetworkManager starts &lt;code&gt;dnsmasq&lt;/code&gt; itself and points &lt;code&gt;/etc/resolv.conf&lt;/code&gt; at
&lt;code&gt;127.0.0.1&lt;/code&gt;. The upstream servers it actually forwards to end up in
&lt;code&gt;/run/NetworkManager/no-stub-resolv.conf&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;To confirm caching is real rather than assumed, run the same lookup twice and
compare query times:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;drill example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;See the &lt;a href=&quot;https://wiki.archlinux.org/title/NetworkManager#dnsmasq&quot;&gt;Arch wiki&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;mDNS and &lt;code&gt;.local&lt;/code&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo pacman -S avahi nss-mdns
sudo systemctl enable --now avahi-daemon.service
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then &lt;code&gt;/etc/nsswitch.conf&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;hosts: mymachines mdns_minimal [NOTFOUND=return] resolve [!UNAVAIL=return] files myhostname dns
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Order matters here. &lt;code&gt;mdns_minimal&lt;/code&gt; has to come before &lt;code&gt;resolve&lt;/code&gt; and &lt;code&gt;dns&lt;/code&gt;, or
&lt;code&gt;.local&lt;/code&gt; names go to the upstream resolver and fail.&lt;/p&gt;
&lt;h3&gt;DNS inside Docker containers&lt;/h3&gt;
&lt;p&gt;If name resolution fails inside a container, check whether you are running a
DNS resolver container. If so, point the host&#39;s &lt;code&gt;/etc/resolv.conf&lt;/code&gt; at something
external such as &lt;code&gt;1.1.1.1&lt;/code&gt;. The container inherits the host&#39;s resolver, and a
resolver that is itself containerised produces a loop.&lt;/p&gt;
&lt;h2&gt;Keyboard&lt;/h2&gt;
&lt;h3&gt;Caps lock, via keyd&lt;/h3&gt;
&lt;p&gt;Install and enable &lt;code&gt;keyd&lt;/code&gt;, then &lt;code&gt;/etc/keyd/default.conf&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[ids]

*

[main]

# Escape when tapped, control when held.
capslock = overload(control, esc)

leftshift+leftmeta = layer(meta)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Works at the evdev layer, so it applies under both X and Wayland, unlike the
various per-compositor remapping options.&lt;/p&gt;
&lt;h3&gt;Vial and QMK keyboards&lt;/h3&gt;
&lt;p&gt;A udev rule is needed before Vial can talk to the board. Get the vendor and
product IDs out of &lt;code&gt;lsusb&lt;/code&gt; first:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;echo &#39;KERNEL==&amp;quot;hidraw*&amp;quot;, SUBSYSTEM==&amp;quot;hidraw&amp;quot;, ATTRS{idVendor}==&amp;quot;3434&amp;quot;, ATTRS{idProduct}==&amp;quot;0350&amp;quot;, MODE=&amp;quot;0660&amp;quot;, GROUP=&amp;quot;users&amp;quot;, TAG+=&amp;quot;uaccess&amp;quot;, TAG+=&amp;quot;udev-acl&amp;quot;&#39; &#92;
  | sudo tee /etc/udev/rules.d/99-vial.rules
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Replace both IDs with your own.&lt;/p&gt;
&lt;h2&gt;Power and boot&lt;/h2&gt;
&lt;h3&gt;AMD P-state&lt;/h3&gt;
&lt;p&gt;Add &lt;code&gt;amd_pstate=active&lt;/code&gt; to the kernel command line. In &lt;code&gt;/etc/default/grub&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;GRUB_CMDLINE_LINUX_DEFAULT=&amp;quot;loglevel=3 quiet nvidia-drm.modeset=1 amd_pstate=active&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;systemd-boot entry&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;title   Arch Linux (linux)
linux   /vmlinuz-linux
initrd  /initramfs-linux.img
options root=PARTUUID=&amp;lt;partuuid&amp;gt; rootflags=subvol=@ rw rootfstype=btrfs nvidia-drm.modeset=1 quiet splash
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Get the PARTUUID from &lt;code&gt;blkid&lt;/code&gt;. To dual boot Windows, copy &lt;code&gt;EFI&#92;Microsoft&lt;/code&gt; to
&lt;code&gt;/boot/EFI&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;Recovering a lost boot entry&lt;/h3&gt;
&lt;p&gt;From a live image:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;mount /dev/nvme0n1p1 /boot
mount /dev/nvme0n1p2 /mnt
arch-chroot /mnt
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;grub-install --target=x86_64-efi --efi-directory=/boot --bootloader-id=arch
grub-mkconfig -o /boot/grub/grub.cfg
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Storage&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;blkid&lt;/code&gt; gives the UUIDs for &lt;code&gt;/etc/fstab&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# &amp;lt;file system&amp;gt; &amp;lt;dir&amp;gt; &amp;lt;type&amp;gt; &amp;lt;options&amp;gt; &amp;lt;dump&amp;gt; &amp;lt;pass&amp;gt;
UUID=&amp;lt;uuid&amp;gt;  /                     ext4  rw,relatime  0 1
UUID=&amp;lt;uuid&amp;gt;  /home/&amp;lt;user&amp;gt;/Storage  ext4  rw,relatime  0 1
UUID=&amp;lt;uuid&amp;gt;  /boot                 vfat  rw,relatime,fmask=0022,dmask=0022,codepage=437,iocharset=ascii,shortname=mixed,utf8,errors=remount-ro  0 2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;btrfs subvolume mount options worth reusing:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;mount -o noatime,compress=zstd,space_cache=v2,subvol=@ /dev/sdX /mnt
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;rclone over SFTP&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;~/.config/rclone/rclone.conf&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ini&quot;&gt;[remote]
type = sftp
host = xfer.hpc.example.edu
user = &amp;lt;username&amp;gt;
key_file = ~/.ssh/id_ed25519
key_file_pass = &amp;lt;obscured&amp;gt;
md5sum_command = md5sum
sha1sum_command = sha1sum
shell_type = unix
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;key_file_pass&lt;/code&gt; is not plaintext. Generate it with
&lt;code&gt;echo &amp;quot;passphrase&amp;quot; | rclone obscure -&lt;/code&gt;, and note that obscuring is obfuscation,
not encryption; the config file still deserves &lt;code&gt;600&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;rclone mount remote:/some/path ~/mnt/path
rclone rcd --rc-web-gui --rc-no-auth   # local web UI
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Desktop odds and ends&lt;/h2&gt;
&lt;p&gt;Default file manager for directories:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;xdg-mime default org.gnome.Nautilus.desktop inode/directory
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A custom &lt;code&gt;.desktop&lt;/code&gt; entry for a terminal program:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[Desktop Entry]
Type=Application
Name=Lynx
Exec=lynx %u
Terminal=true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Packages that come up on every fresh install:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;yay -S nerd-fonts-meta noto-fonts-emoji bibata-cursor-theme-bin ncpamixer
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;SMB in Nautilus needs both &lt;code&gt;samba&lt;/code&gt; and &lt;code&gt;gvfs-smb&lt;/code&gt;, then a restart.&lt;/li&gt;
&lt;li&gt;Bluetooth headphones need &lt;code&gt;pulseaudio-bluetooth&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;gnome-keyring&lt;/code&gt; is what lets VS Code use keyring storage.&lt;/li&gt;
&lt;li&gt;Chinese input: &lt;a href=&quot;https://wiki.archlinux.org/title/Rime&quot;&gt;Rime&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Bluetooth controllers&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;bluetoothctl
scan on
trust  AA:BB:CC:DD:EE:FF
connect AA:BB:CC:DD:EE:FF
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If an Xbox controller refuses to pair, its firmware probably needs updating,
which unfortunately requires Windows.&lt;/p&gt;
&lt;h3&gt;A Windows VM without libvirt&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;qemu-system-x86_64 -enable-kvm -cpu host -smp $(nproc) -m 16G &#92;
  -vga virtio -display gtk,gl=on -usb -device usb-tablet &#92;
  -device ich9-intel-hda -device hda-output &#92;
  -boot d -cdrom ~/Downloads/windows.iso &#92;
  -drive format=qcow2,file=windows
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;gnome-boxes&lt;/code&gt; is the easier route and prefers &lt;code&gt;qcow2&lt;/code&gt; over raw.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Forgejo on a small VPS: a backup forge</title>
    <link href="https://zhengnanli.gitlab.io/blog/forgejo/"/>
    <updated>2026-07-31T00:00:00.000Z</updated>
    <published>2026-07-31T00:00:00.000Z</published>
    <id>https://zhengnanli.gitlab.io/blog/forgejo/</id>
    <summary>A complete self-hosted Forgejo deployment on a small VPS, pull-mirroring Codeberg, GitLab and GitHub. Every config file and script, plus the memory tuning that does not transfer between machines.</summary>
    <content type="html">&lt;p&gt;Every repository I care about lives on Codeberg, GitLab or GitHub. None of those
is a backup, they are just someone else&#39;s disk. This is the full deployment of a
self-hosted Forgejo that pull-mirrors all three onto a VPS I control, including
every config file and script, plus the handful of things that are genuinely easy
to get wrong.&lt;/p&gt;
&lt;p&gt;Two boxes went through this, one with 950 MiB of RAM and one vCPU, and a later
one with 3.7 GB and two. The configs below are the larger box, with the smaller
box&#39;s numbers noted where they differ, because the memory tuning is the one part
you should not copy between machines.&lt;/p&gt;
&lt;p&gt;Throughout: &lt;code&gt;git.example.com&lt;/code&gt; is the forge hostname, &lt;code&gt;myuser&lt;/code&gt; the account,
&lt;code&gt;myorg&lt;/code&gt; an upstream organisation.&lt;/p&gt;
&lt;h2&gt;Install&lt;/h2&gt;
&lt;p&gt;Verify the binary. A forge is the last place to run an unverified download:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;V=16.0.2
curl -fsSL --http1.1 --retry 3 -C - -O &#92;
  https://codeberg.org/forgejo/forgejo/releases/download/v$V/forgejo-$V-linux-amd64
curl -fsSL --http1.1 -O &#92;
  https://codeberg.org/forgejo/forgejo/releases/download/v$V/forgejo-$V-linux-amd64.sha256
curl -fsSL --http1.1 -O &#92;
  https://codeberg.org/forgejo/forgejo/releases/download/v$V/forgejo-$V-linux-amd64.asc

sha256sum -c forgejo-$V-linux-amd64.sha256
gpg --keyserver hkps://keys.openpgp.org &#92;
    --recv-keys EB114F5E6C0DC2BCDD183550A4B61A2DC5923710
gpg --verify forgejo-$V-linux-amd64.asc forgejo-$V-linux-amd64

sudo install -m 755 forgejo-$V-linux-amd64 /usr/local/bin/forgejo
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The checksum file sits on the same server as the binary, so on its own it proves
nothing. The signature is the part that matters, and the key should come from
somewhere other than where the artifact lives.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;--http1.1&lt;/code&gt; is not decoration. Codeberg&#39;s HTTP/2 aborts large transfers from some
hosts with &lt;code&gt;curl 92 ... stream not closed cleanly: CANCEL&lt;/code&gt;, and a 119 MB download
is large enough to hit it. Same flag, same reason, shows up again later for git
itself.&lt;/p&gt;
&lt;p&gt;Then the service account:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt-get install -y nginx certbot git sqlite3 nftables zstd gnupg
sudo adduser --system --shell /bin/bash --gecos &#39;Forgejo&#39; &#92;
     --group --disabled-password --home /home/git git
sudo mkdir -p /var/lib/forgejo/{custom,data,log} /etc/forgejo
sudo chown -R git:git /var/lib/forgejo
sudo chown root:git /etc/forgejo &amp;amp;&amp;amp; sudo chmod 770 /etc/forgejo
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;/etc/forgejo&lt;/code&gt; is &lt;code&gt;root:git&lt;/code&gt; and &lt;code&gt;app.ini&lt;/code&gt; is mode 640, so the service reads its
config but can never write it. That is deliberate, and it is why the OAuth2 JWT
secret has to be set by hand below.&lt;/p&gt;
&lt;p&gt;Generate the secrets before writing the config:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;for s in SECRET_KEY INTERNAL_TOKEN LFS_JWT_SECRET JWT_SECRET; do
  echo &amp;quot;$s = $(forgejo generate secret $s)&amp;quot;
done
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;app.ini&lt;/h2&gt;
&lt;p&gt;Skip the web installer entirely: set &lt;code&gt;INSTALL_LOCK = true&lt;/code&gt; and create the admin
from the CLI, otherwise the setup page is exposed until someone clicks through it.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ini&quot;&gt;; Forgejo configuration for the backup forge (Debian 13, 2 vCPU, 3.7 GB RAM,
; 40 GB disk). Role: pull-mirror of Codeberg, GitLab and GitHub. Not a primary
; forge, so search/CI/packages stay off and the tuning favours reliable large
; clones over interactive responsiveness.

APP_NAME = backup forge
RUN_USER = git
RUN_MODE = prod
WORK_PATH = /var/lib/forgejo

[server]
PROTOCOL         = http
DOMAIN           = git.example.com
ROOT_URL         = https://git.example.com/
; Loopback only; nginx terminates TLS and proxies in.
HTTP_ADDR        = 127.0.0.1
HTTP_PORT        = 3000
DISABLE_SSH      = false
; Built-in SSH server, so the host sshd and /home/git/.ssh stay untouched.
START_SSH_SERVER = true
SSH_LISTEN_HOST  = 0.0.0.0
SSH_LISTEN_PORT  = 2222
SSH_DOMAIN       = git.example.com
SSH_PORT         = 2222
; Forgejo generates RSA only, and will not generate a type it was not told
; about. The ed25519 key is created alongside this file.
SSH_SERVER_HOST_KEYS = ssh/gitea.rsa,ssh/gitea.ed25519
LFS_START_SERVER = true
LFS_JWT_SECRET   = &amp;lt;forgejo generate secret LFS_JWT_SECRET&amp;gt;
OFFLINE_MODE     = true
APP_DATA_PATH    = /var/lib/forgejo/data
; nginx handles compression.
ENABLE_GZIP      = false

[database]
DB_TYPE  = sqlite3
PATH     = /var/lib/forgejo/data/forgejo.db
SQLITE_JOURNAL_MODE = WAL
MAX_OPEN_CONNS = 10
MAX_IDLE_CONNS = 5
CONN_MAX_LIFETIME = 0

[repository]
ROOT = /var/lib/forgejo/data/forgejo-repositories
DEFAULT_BRANCH = main
; A sink, not a source. Discourage accidental local work.
DEFAULT_PRIVATE = private
DEFAULT_PUSH_CREATE_PRIVATE = true
DISABLE_HTTP_GIT = false

[repository.upload]
FILE_MAX_SIZE = 64
MAX_FILES = 10

[repository.pull-request]
DEFAULT_MERGE_STYLE = merge

[mirror]
ENABLED = true
DISABLE_NEW_PULL = false
; Pushing mirrors out of here is not the point; off to avoid surprises.
DISABLE_NEW_PUSH = true
DEFAULT_INTERVAL = 24h
MIN_INTERVAL = 10m

[cron]
ENABLED = true
RUN_AT_START = false

[cron.update_mirrors]
ENABLED = true
; Every 6h; each run only syncs mirrors whose own interval has elapsed.
SCHEDULE = @every 6h
; 2 vCPU, so a little more parallelism than the previous single-core host.
PULL_LIMIT = 4
PUSH_LIMIT = 0

[cron.git_gc_repos]
ENABLED  = true
SCHEDULE = @every 168h
TIMEOUT  = 180m
ARGS     = --aggressive=false

[cron.repo_health_check]
ENABLED  = true
SCHEDULE = @every 168h
TIMEOUT  = 60m

[cron.archive_cleanup]
ENABLED  = true
SCHEDULE = @every 24h
OLDER_THAN = 24h

[cron.cleanup_actions]
ENABLED = true

[cron.delete_old_system_notices]
ENABLED = true
SCHEDULE = @every 168h
OLDER_THAN = 720h

[git]
MAX_GIT_DIFF_LINES = 1000
MAX_GIT_DIFF_LINE_CHARACTERS = 5000
MAX_GIT_DIFF_FILES = 100
GC_ARGS = --no-cruft

[git.timeout]
DEFAULT  = 360
MIGRATE  = 3600
MIRROR   = 1800
CLONE    = 1800
PULL     = 1800
GC       = 600

[git.config]
; Codeberg&#39;s HTTP/2 aborts mid-fetch on large repos (curl 92 / stream CANCEL),
; which surfaces as a failed clone or a confusing &amp;quot;unable to rename temporary
; &#39;*.pack&#39; file&amp;quot; once Forgejo cleans up behind it. Pin HTTP/1.1.
http.version = HTTP/1.1
http.postBuffer = 524288000
; Bounded repack cost. Roomier than a 1 GB host needs, still bounded so one
; huge repo cannot swallow all 3.7 GB.
pack.threads = 2
pack.windowMemory = 256m
pack.packSizeLimit = 1g
pack.deltaCacheSize = 64m
core.bigFileThreshold = 32m
core.packedGitWindowSize = 32m
core.packedGitLimit = 512m
receive.maxInputSize = 0
user.name  = myuser
user.email = me@example.com

[indexer]
; bleve is the heaviest component in a default install and buys nothing on a
; forge whose issues nobody searches. Affordable here, still not worth it.
ISSUE_INDEXER_TYPE = db
REPO_INDEXER_ENABLED = false

[queue]
TYPE = level
DATADIR = /var/lib/forgejo/data/queues/
LENGTH = 200
BATCH_LENGTH = 20
MAX_WORKERS = 2

[cache]
ENABLED = true
ADAPTER = memory
INTERVAL = 60
HOST =
ITEM_TTL = 8h

[session]
PROVIDER = file
PROVIDER_CONFIG = /var/lib/forgejo/data/sessions
COOKIE_SECURE = true
SESSION_LIFE_TIME = 604800

[security]
INSTALL_LOCK = true
SECRET_KEY = &amp;lt;forgejo generate secret SECRET_KEY&amp;gt;
INTERNAL_TOKEN = &amp;lt;forgejo generate secret INTERNAL_TOKEN&amp;gt;
PASSWORD_HASH_ALGO = argon2
PASSWORD_COMPLEXITY = lower,upper,digit
MIN_PASSWORD_LENGTH = 12
REVERSE_PROXY_LIMIT = 1
REVERSE_PROXY_TRUSTED_PROXIES = 127.0.0.0/8,::1/128
LOGIN_REMEMBER_DAYS = 30

[service]
DISABLE_REGISTRATION              = true
ALLOW_ONLY_EXTERNAL_REGISTRATION  = false
REGISTER_EMAIL_CONFIRM            = false
ENABLE_NOTIFY_MAIL                = false
REQUIRE_SIGNIN_VIEW               = true
DEFAULT_KEEP_EMAIL_PRIVATE        = true
DEFAULT_ALLOW_CREATE_ORGANIZATION = true
ENABLE_TIMETRACKING               = false
NO_REPLY_ADDRESS                  = noreply.git.example.com
ENABLE_CAPTCHA                    = false

[oauth2]
; Set explicitly, so Forgejo never needs to write to this file. It treats a
; failed write of a generated JWT secret as fatal.
ENABLED = false
JWT_SECRET = &amp;lt;forgejo generate secret JWT_SECRET&amp;gt;

[openid]
ENABLE_OPENID_SIGNIN = false
ENABLE_OPENID_SIGNUP = false

[actions]
ENABLED = false

[packages]
; Package storage would compete with repository data for the same disk.
ENABLED = false

[ui]
EXPLORE_PAGING_NUM = 20
ISSUE_PAGING_NUM   = 20
FEED_MAX_COMMIT_NUM = 5
GRAPH_MAX_COMMIT_NUM = 100
DEFAULT_THEME = forgejo-auto

[log]
MODE = console
LEVEL = info
ROOT_PATH = /var/lib/forgejo/log

[log.console]
STDERR = false

[metrics]
ENABLED = false

[federation]
ENABLED = false

[other]
SHOW_FOOTER_VERSION = false
SHOW_FOOTER_TEMPLATE_LOAD_TIME = false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The parts worth explaining:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;ISSUE_INDEXER_TYPE = db&lt;/code&gt;.&lt;/strong&gt; A default install runs bleve, the heaviest
component in the whole process, to make issues searchable. On a forge whose
issues nobody reads, that is pure overhead. On the 950 MiB box it was the single
biggest win.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;[oauth2] JWT_SECRET&lt;/code&gt; set explicitly.&lt;/strong&gt; Leave it out and Forgejo generates one
at startup, tries to write it back into &lt;code&gt;app.ini&lt;/code&gt;, fails because the file is not
writable by &lt;code&gt;git&lt;/code&gt;, and treats that as fatal. The symptom is a restart loop with
&lt;code&gt;save oauth2.JWT_SECRET failed: permission denied&lt;/code&gt;, which does not obviously point
at file permissions.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;SSH_SERVER_HOST_KEYS&lt;/code&gt; names ed25519.&lt;/strong&gt; Forgejo generates RSA only, and will not
generate a key type it was not told about, so create it yourself:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo mkdir -p /var/lib/forgejo/data/ssh
sudo chown -R git:git /var/lib/forgejo/data/ssh
sudo chmod 700 /var/lib/forgejo/data/ssh
sudo -u git ssh-keygen -t ed25519 -N &#39;&#39; -f /var/lib/forgejo/data/ssh/gitea.ed25519
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Create and &lt;code&gt;chown&lt;/code&gt; the directory before running &lt;code&gt;ssh-keygen&lt;/code&gt; as &lt;code&gt;git&lt;/code&gt;, or it fails
with permission denied.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;http.version = HTTP/1.1&lt;/code&gt; under &lt;code&gt;[git.config]&lt;/code&gt;.&lt;/strong&gt; The same Codeberg HTTP/2
problem as the download. Without it, large mirror clones fail, sometimes as the
genuinely baffling &lt;code&gt;unable to rename temporary &#39;*.pack&#39; file to ...: No such file or directory&lt;/code&gt;, which is a secondary symptom: git&#39;s transfer died, Forgejo removed
the half-built repository, and git then tried to finish writing into a directory
that no longer existed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;[git.config]&lt;/code&gt; at all.&lt;/strong&gt; Forgejo writes that section into its own &lt;code&gt;.gitconfig&lt;/code&gt;,
so it applies to every &lt;code&gt;git&lt;/code&gt; it forks. That matters because the process which
exhausts a small box is never Forgejo, it is a &lt;code&gt;git repack&lt;/code&gt; on a large mirror.&lt;/p&gt;
&lt;h2&gt;The unit file&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-ini&quot;&gt;[Unit]
Description=Forgejo (backup mirror forge)
Documentation=https://forgejo.org/docs/
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=git
Group=git
WorkingDirectory=/var/lib/forgejo/
RuntimeDirectory=forgejo
RuntimeDirectoryMode=0750

ExecStart=/usr/local/bin/forgejo web --config /etc/forgejo/app.ini
Restart=always
RestartSec=10s

Environment=USER=git HOME=/home/git GITEA_WORK_DIR=/var/lib/forgejo
# 3.7 GB box: give the heap real room, but still bounded.
Environment=GOMEMLIMIT=1400MiB
Environment=GOMAXPROCS=2

# --- Resource containment -------------------------------------------------
# MemoryHigh throttles and swaps first; MemoryMax is the hard wall. A runaway
# `git repack` dies inside this cgroup instead of letting the kernel OOM killer
# pick a victim by badness score (which could just as easily be sshd).
MemoryHigh=2000M
MemoryMax=2800M
MemorySwapMax=1500M

# --- Hardening -----------------------------------------------------------
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectClock=true
ProtectHostname=true
ProtectProc=invisible
RestrictSUIDSGID=true
RestrictRealtime=true
RestrictNamespaces=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
LockPersonality=true
# Loose enough not to break git repacks.
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
CapabilityBoundingSet=
AmbientCapabilities=

# ProtectSystem=strict makes / read-only; punch through only what we own.
ReadWritePaths=/var/lib/forgejo
ReadWritePaths=/etc/forgejo
ReadWritePaths=/home/git

[Install]
WantedBy=multi-user.target
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;MemoryHigh&lt;/code&gt; throttles and pushes to swap first, &lt;code&gt;MemoryMax&lt;/code&gt; is the hard wall. The
point is not to be stingy, it is that without a cgroup limit the kernel OOM killer
picks its victim by badness score, and on a small box that is as likely to be
&lt;code&gt;sshd&lt;/code&gt; as the process that actually misbehaved. Bounding the service means a
runaway repack dies alone and everything else survives.&lt;/p&gt;
&lt;p&gt;Sized for 3.7 GB above. The 950 MiB box used &lt;code&gt;GOMEMLIMIT=380MiB&lt;/code&gt;,
&lt;code&gt;MemoryHigh=520M&lt;/code&gt;, &lt;code&gt;MemoryMax=760M&lt;/code&gt;, and correspondingly &lt;code&gt;pack.threads = 1&lt;/code&gt;,
&lt;code&gt;pack.windowMemory = 96m&lt;/code&gt;, &lt;code&gt;PULL_LIMIT = 2&lt;/code&gt;. Idle footprint was about 230 MB there
and 110 MB here. Do not copy these numbers between machines in either direction.&lt;/p&gt;
&lt;p&gt;If the box has no swap, add some, or &lt;code&gt;MemorySwapMax&lt;/code&gt; has nowhere to spill:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo fallocate -l 2G /swapfile &amp;amp;&amp;amp; sudo chmod 600 /swapfile
sudo mkswap /swapfile &amp;amp;&amp;amp; sudo swapon /swapfile
echo &#39;/swapfile none swap sw 0 0&#39; | sudo tee -a /etc/fstab
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;One firewall, not two&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;#!/usr/sbin/nft -f
# Single source of truth for inbound filtering on this host.
#
# Deliberately the ONLY input firewall here: multiple nftables base chains can
# attach to the same hook at the same priority and all of them run, so a second
# tool (ufw, firewalld) layered on top means every future port has to be opened
# in both places or it silently fails.
#
# Apply with: sudo nft -f /etc/nftables.conf   (nftables.service does this at boot)

table inet filter
delete table inet filter

table inet filter {
	chain input {
		type filter hook input priority filter; policy drop;

		iifname &amp;quot;lo&amp;quot; accept
		ct state established,related accept
		ct state invalid drop

		icmp type { echo-request, destination-unreachable, time-exceeded, parameter-problem } accept
		icmpv6 type { echo-request, destination-unreachable, time-exceeded, parameter-problem, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept

		tcp dport 22 accept   # host sshd
		tcp dport 80 accept   # nginx: ACME HTTP-01 + redirect to https
		tcp dport 443 accept  # nginx -&amp;gt; Forgejo
		tcp dport 2222 accept # Forgejo built-in SSH (git over ssh)

		udp dport 68 accept   # DHCP lease renewal (v4)
		udp dport 546 accept  # DHCP lease renewal (v6)
	}

	chain forward {
		type filter hook forward priority filter; policy drop;
	}

	chain output {
		type filter hook output priority filter; policy accept;
	}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo nft -f /etc/nftables.conf
sudo nft list chain inet filter input   # confirm the port 22 rule BEFORE logging out
sudo systemctl enable --now nftables
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the part I got wrong, and it cost the most time, so it is worth being
precise about why.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Multiple nftables base chains can attach to the same hook at the same priority,
and every one of them runs.&lt;/strong&gt; A packet has to be accepted by all of them, and a
&lt;code&gt;drop&lt;/code&gt; in any single chain is final. There is no first-match-wins across chains
and no precedence to reason about.&lt;/p&gt;
&lt;p&gt;So on a host that already had a &lt;code&gt;policy drop&lt;/code&gt; table from an unrelated service, my
freshly configured ufw reported &lt;code&gt;443 ALLOW IN&lt;/code&gt; and was completely powerless,
because the other table only ever allowed &lt;code&gt;udp dport 443&lt;/code&gt; and never &lt;code&gt;tcp&lt;/code&gt;. The
service answered perfectly over loopback and was unreachable from the internet.&lt;/p&gt;
&lt;p&gt;The counters say so plainly, if you look:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;chain ufw-user-input {
  tcp dport 22   counter packets 40 bytes 2320 accept
  tcp dport 443  counter packets 0  bytes 0    accept
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Zero packets on a rule that should be busy means that rule is not in the path.
Read the whole ruleset with &lt;code&gt;nft list ruleset&lt;/code&gt;, not just the table you wrote.&lt;/p&gt;
&lt;h2&gt;TLS&lt;/h2&gt;
&lt;p&gt;Get the certificate before writing a vhost that references it, since nginx will
not start on a missing certificate file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo certbot certonly --webroot -w /var/www/html -d git.example.com &#92;
     --non-interactive --agree-tos -m me@example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-nginx&quot;&gt;
# git.example.com -&amp;gt; Forgejo on 127.0.0.1:3000
#
# Works both DNS-only and behind the Cloudflare proxy. When the orange cloud
# is on, real client IPs arrive in CF-Connecting-IP and are restored by
# /etc/nginx/conf.d/cloudflare-realip.conf.

upstream forgejo {
    server 127.0.0.1:3000;
    keepalive 4;
}

server {
    listen 80;
    listen [::]:80;
    server_name git.example.com;

    # Leave room for certbot renewals over HTTP-01.
    location /.well-known/acme-challenge/ {
        root /var/www/html;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name git.example.com;

    ssl_certificate     /etc/letsencrypt/live/git.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/git.example.com/privkey.pem;
    ssl_trusted_certificate /etc/letsencrypt/live/git.example.com/chain.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;
    ssl_session_cache shared:SSL:2m;
    ssl_session_timeout 1d;

    add_header Strict-Transport-Security &amp;quot;max-age=31536000&amp;quot; always;
    add_header X-Content-Type-Options &amp;quot;nosniff&amp;quot; always;
    add_header Referrer-Policy &amp;quot;strict-origin-when-cross-origin&amp;quot; always;

    # Git pushes and LFS uploads can be large; do not cap them at the proxy.
    # (Note: the Cloudflare free plan enforces its own 100 MB request cap.)
    client_max_body_size 0;
    # Stream request bodies straight through instead of spooling to disk;
    # /tmp is a 476 MB tmpfs on this host, so buffering a big push would
    # consume RAM.
    proxy_request_buffering off;
    proxy_buffering off;
    proxy_http_version 1.1;

    # Large clones on 1 vCPU are slow. Be patient.
    proxy_connect_timeout 60s;
    proxy_send_timeout    900s;
    proxy_read_timeout    900s;

    location / {
        proxy_pass http://forgejo;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header X-Forwarded-Host  $host;
        proxy_set_header Connection        &amp;quot;&amp;quot;;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;client_max_body_size 0&lt;/code&gt; and &lt;code&gt;proxy_request_buffering off&lt;/code&gt; are both about git.
Unbuffered matters more than it looks: if &lt;code&gt;/tmp&lt;/code&gt; is a tmpfs, which it is by
default on recent Debian and Ubuntu, then buffering a large push spools it into
RAM.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;certonly&lt;/code&gt; installs &lt;strong&gt;no reload hook&lt;/strong&gt;, so a renewal two months out would quietly
keep serving the old certificate until something restarted nginx:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh &amp;gt;/dev/null &amp;lt;&amp;lt;&#39;EOF&#39;
#!/bin/sh
set -e
nginx -t &amp;amp;&amp;amp; systemctl reload nginx
EOF
sudo chmod 755 /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
sudo certbot renew --dry-run
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Delete any &lt;code&gt;ssl_stapling&lt;/code&gt; lines from your boilerplate too. Let&#39;s Encrypt no longer
runs OCSP responders, so they only produce a warning.&lt;/p&gt;
&lt;h2&gt;Behind Cloudflare&lt;/h2&gt;
&lt;p&gt;If the domain is already on Cloudflare, restore real client IPs at the origin or
every log line and rate limit sees the edge instead of the client:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/bin/bash
# Refresh the nginx set_real_ip_from list from Cloudflare published ranges.
# Safe to run while DNS-only: set_real_ip_from only trusts CF-Connecting-IP
# when the request actually arrives from one of these ranges.
set -euo pipefail
OUT=/etc/nginx/conf.d/cloudflare-realip.conf
TMP=$(mktemp)
cleanup() { rm -f &amp;quot;$TMP&amp;quot;; }
trap cleanup EXIT
{
  echo &amp;quot;# Generated by cloudflare-realip-update on $(date -Is). Do not edit.&amp;quot;
  for u in https://www.cloudflare.com/ips-v4 https://www.cloudflare.com/ips-v6; do
    curl -fsS --max-time 20 &amp;quot;$u&amp;quot; | sed &amp;quot;s/^/set_real_ip_from /; s/$/;/&amp;quot;
  done
  echo &amp;quot;real_ip_header CF-Connecting-IP;&amp;quot;
  echo &amp;quot;real_ip_recursive on;&amp;quot;
} &amp;gt; &amp;quot;$TMP&amp;quot;
grep -q &amp;quot;^set_real_ip_from&amp;quot; &amp;quot;$TMP&amp;quot; || { echo &amp;quot;no ranges fetched; keeping existing&amp;quot; &amp;gt;&amp;amp;2; exit 1; }
install -m 644 &amp;quot;$TMP&amp;quot; &amp;quot;$OUT&amp;quot;
nginx -t &amp;amp;&amp;amp; systemctl reload nginx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Safe to install before enabling the proxy, because &lt;code&gt;set_real_ip_from&lt;/code&gt; only trusts
&lt;code&gt;CF-Connecting-IP&lt;/code&gt; when the request actually arrives from one of those ranges, so
a direct client cannot forge it. Refresh it monthly from cron.&lt;/p&gt;
&lt;p&gt;Then orange-cloud the record and set SSL/TLS to &lt;strong&gt;Full (strict)&lt;/strong&gt;, which works
because the origin has a real certificate. Three limits to know first:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The free plan caps request bodies at 100 MB.&lt;/strong&gt; That is a push limit, not a
clone limit, and pull mirrors are unaffected because the server dials upstream
directly rather than through the edge.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SSH is not proxied&lt;/strong&gt; without Spectrum, so git over SSH connects to the origin
address directly, which also discloses the IP the orange cloud would otherwise
conceal.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A slow &lt;code&gt;upload-pack&lt;/code&gt; can exceed the 100 s edge timeout&lt;/strong&gt; and surface as a 524.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Confirm the edge actually reaches your origin by forcing a Cloudflare address
rather than trusting DNS:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -sI --resolve git.example.com:443:104.21.12.54 https://git.example.com/ &#92;
  | grep -iE &#39;HTTP|server|cf-ray&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A &lt;code&gt;cf-ray&lt;/code&gt; header means you went through the edge. And once real-IP restoration
is working, Cloudflare&#39;s own addresses stop appearing in the nginx access log,
which is the feature working rather than a failure.&lt;/p&gt;
&lt;h2&gt;Admin user and token&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;FJ() { sudo -u git env GITEA_WORK_DIR=/var/lib/forgejo HOME=/home/git &#92;
       /usr/local/bin/forgejo &amp;quot;$@&amp;quot; --config /etc/forgejo/app.ini; }

FJ admin user create --admin --username myuser --email me@example.com &#92;
   --password &amp;quot;$PW&amp;quot; --must-change-password=false

FJ admin user generate-access-token --username myuser &#92;
   --token-name mirror-bootstrap &#92;
   --scopes write:repository,write:user,write:organization --raw
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;write:organization&lt;/code&gt; is required if any upstream repositories live under an
organisation. &lt;code&gt;write:repository&lt;/code&gt; does not imply it, and the failure arrives as
&lt;code&gt;token does not have at least one of required scope(s)&lt;/code&gt; only once you are already
mid-run.&lt;/p&gt;
&lt;h2&gt;Creating the mirrors&lt;/h2&gt;
&lt;p&gt;Doing dozens of repositories by hand is not the intent. This enumerates each
upstream account and creates a pull mirror per repository:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;#!/usr/bin/env python3
&amp;quot;&amp;quot;&amp;quot;Bulk-create Forgejo pull mirrors of a GitHub / GitLab / Codeberg account.

Reads credentials from /etc/forgejo/mirror-tokens.env (mode 600).

    forgejo-add-mirrors codeberg
    forgejo-add-mirrors github
    forgejo-add-mirrors gitlab
    forgejo-add-mirrors all
    DRY_RUN=1 forgejo-add-mirrors all     # show a plan, change nothing

Idempotent: a repo that already exists locally is skipped, so re-running after
creating new upstream repos only adds the new ones.

Two things this gets right that are easy to get wrong:

  * Upstream credentials are passed as `auth_token` ONLY. Supplying
    `auth_username` alongside it makes Forgejo present the *username* to the
    upstream as the token, which fails with a confusing auth cascade
    (&amp;quot;access token does not exist [sha: &amp;lt;username&amp;gt;]&amp;quot;).

  * Upstream organisation structure is preserved. A local org is created for
    each upstream owner that is not you, so `myorg/thing` mirrors to
    `myorg/thing` rather than colliding with your own `thing`.
&amp;quot;&amp;quot;&amp;quot;

import json
import os
import subprocess
import sys
import time
import urllib.error
import urllib.request

ENV_FILE = &amp;quot;/etc/forgejo/mirror-tokens.env&amp;quot;
FORGEJO_API = os.environ.get(&amp;quot;FORGEJO_API&amp;quot;, &amp;quot;http://127.0.0.1:3000/api/v1&amp;quot;)
DRY_RUN = os.environ.get(&amp;quot;DRY_RUN&amp;quot;, &amp;quot;0&amp;quot;) == &amp;quot;1&amp;quot;
# Seconds to pause between migrations. This box has 1 vCPU; each migration
# clones synchronously, so back-to-back requests just queue up behind git.
CREATE_DELAY = float(os.environ.get(&amp;quot;CREATE_DELAY&amp;quot;, &amp;quot;3&amp;quot;))
INTERVAL = os.environ.get(&amp;quot;INTERVAL&amp;quot;, &amp;quot;24h&amp;quot;)
TIMEOUT = int(os.environ.get(&amp;quot;HTTP_TIMEOUT&amp;quot;, &amp;quot;1800&amp;quot;))


def log(*a):
    print(time.strftime(&amp;quot;[%H:%M:%S]&amp;quot;), *a, flush=True)


def load_env(path):
    vals = {}
    try:
        text = open(path).read()
    except OSError as e:
        sys.exit(&amp;quot;cannot read %s: %s&amp;quot; % (path, e))
    for line in text.splitlines():
        line = line.strip()
        if not line or line.startswith(&amp;quot;#&amp;quot;) or &amp;quot;=&amp;quot; not in line:
            continue
        k, v = line.split(&amp;quot;=&amp;quot;, 1)
        vals[k.strip()] = v.strip().strip(&amp;quot;&#92;&amp;quot;&#39;&amp;quot;)
    return vals


ENV = load_env(ENV_FILE)


def need(key):
    v = ENV.get(key)
    if not v:
        sys.exit(&amp;quot;set %s in %s&amp;quot; % (key, ENV_FILE))
    return v


def request(url, token=None, method=&amp;quot;GET&amp;quot;, data=None, header=&amp;quot;token&amp;quot;):
    req = urllib.request.Request(url, method=method)
    if token:
        if header == &amp;quot;token&amp;quot;:
            req.add_header(&amp;quot;Authorization&amp;quot;, &amp;quot;token &amp;quot; + token)
        elif header == &amp;quot;bearer&amp;quot;:
            req.add_header(&amp;quot;Authorization&amp;quot;, &amp;quot;Bearer &amp;quot; + token)
        else:
            req.add_header(header, token)
    req.add_header(&amp;quot;Accept&amp;quot;, &amp;quot;application/json&amp;quot;)
    if data is not None:
        req.add_header(&amp;quot;Content-Type&amp;quot;, &amp;quot;application/json&amp;quot;)
        data = json.dumps(data).encode()
    try:
        with urllib.request.urlopen(req, data, timeout=TIMEOUT) as r:
            body = r.read().decode()
            return r.status, (json.loads(body) if body.strip() else None)
    except urllib.error.HTTPError as e:
        body = e.read().decode()
        try:
            return e.code, json.loads(body)
        except Exception:
            return e.code, {&amp;quot;message&amp;quot;: body[:400]}
    except Exception as e:
        return 0, {&amp;quot;message&amp;quot;: str(e)}


# --------------------------------------------------------------------------
# Local Forgejo side
# --------------------------------------------------------------------------
FJ_TOKEN = need(&amp;quot;FORGEJO_TOKEN&amp;quot;)

status, me = request(FORGEJO_API + &amp;quot;/user&amp;quot;, FJ_TOKEN)
if status != 200 or not me:
    sys.exit(&amp;quot;cannot authenticate to Forgejo (%s): %s&amp;quot; % (status, me))
FJ_USER = me[&amp;quot;login&amp;quot;]
log(&amp;quot;authenticated to Forgejo as %s&amp;quot; % FJ_USER)

_known_orgs = set()


def ensure_org(name):
    &amp;quot;&amp;quot;&amp;quot;Make sure a local org exists to hold an upstream owner&#39;s repos.&amp;quot;&amp;quot;&amp;quot;
    if name.lower() == FJ_USER.lower() or name in _known_orgs:
        return
    status, _ = request(&amp;quot;%s/orgs/%s&amp;quot; % (FORGEJO_API, name), FJ_TOKEN)
    if status == 200:
        _known_orgs.add(name)
        return
    if DRY_RUN:
        log(&amp;quot;  DRY_RUN would create local org %s&amp;quot; % name)
        _known_orgs.add(name)
        return
    status, resp = request(FORGEJO_API + &amp;quot;/orgs&amp;quot;, FJ_TOKEN, &amp;quot;POST&amp;quot;, {
        &amp;quot;username&amp;quot;: name,
        &amp;quot;visibility&amp;quot;: &amp;quot;private&amp;quot;,
        &amp;quot;description&amp;quot;: &amp;quot;Mirrored organisation&amp;quot;,
    })
    if status in (200, 201):
        log(&amp;quot;  created local org %s&amp;quot; % name)
        _known_orgs.add(name)
    else:
        log(&amp;quot;  WARNING could not create org %s (%s): %s&amp;quot;
            % (name, status, (resp or {}).get(&amp;quot;message&amp;quot;)))


def local_repo(owner, name):
    status, body = request(&amp;quot;%s/repos/%s/%s&amp;quot; % (FORGEJO_API, owner, name), FJ_TOKEN)
    return body if status == 200 else None


def create_mirror(clone_url, owner, name, token, service, upstream_empty=False):
    &amp;quot;&amp;quot;&amp;quot;Create one pull mirror. `token` is the UPSTREAM credential.&amp;quot;&amp;quot;&amp;quot;
    existing = local_repo(owner, name)
    if existing:
        # A failed migration leaves a database record with no git data behind.
        # Skipping that forever would silently keep a broken mirror, so rebuild
        # it -- but only when upstream actually has commits to fetch.
        if existing.get(&amp;quot;empty&amp;quot;) and not upstream_empty:
            if DRY_RUN:
                log(&amp;quot;  DRY_RUN would REBUILD %s/%s (exists but empty)&amp;quot; % (owner, name))
                return &amp;quot;planned&amp;quot;
            log(&amp;quot;  rebuilding %s/%s (exists but empty)&amp;quot; % (owner, name))
            status, resp = request(&amp;quot;%s/repos/%s/%s&amp;quot; % (FORGEJO_API, owner, name),
                                   FJ_TOKEN, &amp;quot;DELETE&amp;quot;)
            if status not in (200, 204):
                log(&amp;quot;  FAILED to delete broken %s/%s (%s): %s&amp;quot;
                    % (owner, name, status, (resp or {}).get(&amp;quot;message&amp;quot;)))
                return &amp;quot;failed&amp;quot;
        else:
            log(&amp;quot;  skip %s/%s (exists)&amp;quot; % (owner, name))
            return &amp;quot;skipped&amp;quot;

    if DRY_RUN:
        log(&amp;quot;  DRY_RUN would mirror %s -&amp;gt; %s/%s&amp;quot; % (clone_url, owner, name))
        return &amp;quot;planned&amp;quot;

    ensure_org(owner)
    payload = {
        &amp;quot;clone_addr&amp;quot;: clone_url,
        &amp;quot;repo_name&amp;quot;: name,
        &amp;quot;repo_owner&amp;quot;: owner,
        &amp;quot;service&amp;quot;: service,
        &amp;quot;mirror&amp;quot;: True,
        &amp;quot;mirror_interval&amp;quot;: INTERVAL,
        &amp;quot;private&amp;quot;: True,
        &amp;quot;wiki&amp;quot;: True,
        &amp;quot;labels&amp;quot;: True,
        &amp;quot;issues&amp;quot;: True,
        &amp;quot;milestones&amp;quot;: True,
        &amp;quot;releases&amp;quot;: True,
        &amp;quot;pull_requests&amp;quot;: False,
        &amp;quot;lfs&amp;quot;: False,
        &amp;quot;description&amp;quot;: &amp;quot;Backup mirror of &amp;quot; + clone_url,
        # auth_token ONLY -- see module docstring.
        &amp;quot;auth_token&amp;quot;: token,
    }
    status, resp = request(FORGEJO_API + &amp;quot;/repos/migrate&amp;quot;, FJ_TOKEN, &amp;quot;POST&amp;quot;, payload)
    if status in (200, 201):
        log(&amp;quot;  mirrored %s/%s&amp;quot; % (owner, name))
        time.sleep(CREATE_DELAY)
        return &amp;quot;created&amp;quot;
    msg = (resp or {}).get(&amp;quot;message&amp;quot;, &amp;quot;&amp;quot;)
    log(&amp;quot;  FAILED %s/%s (%s): %s&amp;quot; % (owner, name, status, str(msg).replace(&amp;quot;&#92;n&amp;quot;, &amp;quot; | &amp;quot;)[:300]))
    return &amp;quot;failed&amp;quot;


def local_owner_for(upstream_owner, upstream_user):
    &amp;quot;&amp;quot;&amp;quot;Own repos go under your account; everything else under a local org.&amp;quot;&amp;quot;&amp;quot;
    if upstream_owner.lower() == upstream_user.lower():
        return FJ_USER
    return upstream_owner.replace(&amp;quot;/&amp;quot;, &amp;quot;-&amp;quot;)


# --------------------------------------------------------------------------
# Upstream enumeration
# --------------------------------------------------------------------------
def paged(url_tmpl, token, header, per_page=50, limit_key=&amp;quot;limit&amp;quot;):
    page = 1
    while True:
        url = url_tmpl % {&amp;quot;page&amp;quot;: page, &amp;quot;per&amp;quot;: per_page, &amp;quot;limit&amp;quot;: limit_key}
        status, body = request(url, token, header=header)
        if status != 200 or not body:
            if status != 200:
                log(&amp;quot;  enumeration stopped at page %d (HTTP %s): %s&amp;quot;
                    % (page, status, (body or {}).get(&amp;quot;message&amp;quot;)))
            return
        if not isinstance(body, list) or not body:
            return
        for item in body:
            yield item
        if len(body) &amp;lt; per_page:
            return
        page += 1


def do_codeberg(stats):
    token = need(&amp;quot;CODEBERG_TOKEN&amp;quot;)
    user = need(&amp;quot;CODEBERG_USER&amp;quot;)
    log(&amp;quot;enumerating Codeberg repos for %s&amp;quot; % user)
    url = &amp;quot;https://codeberg.org/api/v1/user/repos?limit=%(per)d&amp;amp;page=%(page)d&amp;quot;
    for r in paged(url, token, &amp;quot;token&amp;quot;):
        owner = local_owner_for(r[&amp;quot;owner&amp;quot;][&amp;quot;login&amp;quot;], user)
        stats[create_mirror(r[&amp;quot;clone_url&amp;quot;], owner, r[&amp;quot;name&amp;quot;], token, &amp;quot;gitea&amp;quot;,
                            bool(r.get(&amp;quot;empty&amp;quot;)))] += 1


def do_github(stats):
    token = need(&amp;quot;GITHUB_TOKEN&amp;quot;)
    user = need(&amp;quot;GITHUB_USER&amp;quot;)
    log(&amp;quot;enumerating GitHub repos for %s&amp;quot; % user)
    url = (&amp;quot;https://api.github.com/user/repos?per_page=%(per)d&amp;amp;page=%(page)d&amp;quot;
           &amp;quot;&amp;amp;affiliation=owner,organization_member&amp;quot;)
    for r in paged(url, token, &amp;quot;bearer&amp;quot;):
        owner = local_owner_for(r[&amp;quot;owner&amp;quot;][&amp;quot;login&amp;quot;], user)
        stats[create_mirror(r[&amp;quot;clone_url&amp;quot;], owner, r[&amp;quot;name&amp;quot;], token, &amp;quot;github&amp;quot;,
                            bool(r.get(&amp;quot;size&amp;quot;) == 0))] += 1


def do_gitlab(stats):
    token = need(&amp;quot;GITLAB_TOKEN&amp;quot;)
    log(&amp;quot;enumerating GitLab projects&amp;quot;)
    status, me_gl = request(&amp;quot;https://gitlab.com/api/v4/user&amp;quot;, token, header=&amp;quot;PRIVATE-TOKEN&amp;quot;)
    gl_user = (me_gl or {}).get(&amp;quot;username&amp;quot;, &amp;quot;&amp;quot;)
    url = (&amp;quot;https://gitlab.com/api/v4/projects?membership=true&amp;amp;per_page=%(per)d&amp;quot;
           &amp;quot;&amp;amp;page=%(page)d&amp;quot;)
    for r in paged(url, token, &amp;quot;PRIVATE-TOKEN&amp;quot;):
        ns = r.get(&amp;quot;namespace&amp;quot;, {}).get(&amp;quot;full_path&amp;quot;, &amp;quot;&amp;quot;)
        owner = local_owner_for(ns, gl_user)
        stats[create_mirror(r[&amp;quot;http_url_to_repo&amp;quot;], owner, r[&amp;quot;path&amp;quot;], token, &amp;quot;gitlab&amp;quot;,
                            bool(r.get(&amp;quot;empty_repo&amp;quot;)))] += 1


def disk_free():
    try:
        out = subprocess.run([&amp;quot;df&amp;quot;, &amp;quot;-h&amp;quot;, &amp;quot;--output=avail&amp;quot;, &amp;quot;/&amp;quot;],
                             capture_output=True, text=True).stdout
        return out.strip().splitlines()[-1].strip()
    except Exception:
        return &amp;quot;?&amp;quot;


def main():
    if len(sys.argv) != 2 or sys.argv[1] not in (&amp;quot;github&amp;quot;, &amp;quot;gitlab&amp;quot;, &amp;quot;codeberg&amp;quot;, &amp;quot;all&amp;quot;):
        sys.exit(&amp;quot;usage: forgejo-add-mirrors {github|gitlab|codeberg|all}&amp;quot;)
    which = sys.argv[1]
    if DRY_RUN:
        log(&amp;quot;DRY_RUN=1, nothing will be created&amp;quot;)
    log(&amp;quot;disk free before: %s&amp;quot; % disk_free())

    stats = {&amp;quot;created&amp;quot;: 0, &amp;quot;skipped&amp;quot;: 0, &amp;quot;failed&amp;quot;: 0, &amp;quot;planned&amp;quot;: 0}
    for name, fn in ((&amp;quot;codeberg&amp;quot;, do_codeberg), (&amp;quot;github&amp;quot;, do_github), (&amp;quot;gitlab&amp;quot;, do_gitlab)):
        if which in (name, &amp;quot;all&amp;quot;):
            if which == &amp;quot;all&amp;quot; and not any(
                    ENV.get(k) for k in (&amp;quot;%s_TOKEN&amp;quot; % name.upper(),)):
                log(&amp;quot;skipping %s (no token configured)&amp;quot; % name)
                continue
            fn(stats)

    log(&amp;quot;done: %d created, %d skipped, %d failed, %d planned&amp;quot;
        % (stats[&amp;quot;created&amp;quot;], stats[&amp;quot;skipped&amp;quot;], stats[&amp;quot;failed&amp;quot;], stats[&amp;quot;planned&amp;quot;]))
    log(&amp;quot;disk free after: %s&amp;quot; % disk_free())
    if stats[&amp;quot;failed&amp;quot;]:
        log(&amp;quot;check failures with: journalctl -u forgejo --since &#39;1 hour ago&#39; | grep -i migrat&amp;quot;)
        return 1
    return 0


if __name__ == &amp;quot;__main__&amp;quot;:
    sys.exit(main())
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo DRY_RUN=1 forgejo-add-mirrors codeberg   # plan
sudo forgejo-add-mirrors codeberg             # execute
sudo forgejo-add-mirrors all
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three traps in there, each of which I hit:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Pass &lt;code&gt;auth_token&lt;/code&gt; alone.&lt;/strong&gt; Supplying &lt;code&gt;auth_username&lt;/code&gt; next to it makes Forgejo
present the &lt;em&gt;username&lt;/em&gt; to the upstream as the credential. The upstream rejects it
and its error comes back wrapped in your own API&#39;s error envelope, complete with a
&lt;code&gt;&amp;quot;url&amp;quot;&lt;/code&gt; pointing at your own swagger endpoint, so it reads like a local
authentication failure:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;token is malformed: token contains an invalid number of segments
user&#39;s password is invalid [uid: NNNNNN, name: myuser]
access token does not exist [sha: myuser]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That last line is the giveaway: the upstream is looking up a token whose value is
your username. What makes it confusing is that both plain forms work fine against
the upstream API directly, and only the mixed form fails.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Derive local names from the full upstream path, not &lt;code&gt;basename&lt;/code&gt;.&lt;/strong&gt; If some
repositories live under an organisation, &lt;code&gt;myorg/thing&lt;/code&gt; and &lt;code&gt;myuser/thing&lt;/code&gt; both
reduce to &lt;code&gt;thing&lt;/code&gt; and the second silently collides with the first. Create a local
organisation per upstream owner and mirror into it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Treat an empty local repository as a failure, not as done.&lt;/strong&gt; Forgejo inserts the
repository row &lt;em&gt;before&lt;/em&gt; it clones, so an interrupted migration leaves a record
with &lt;code&gt;empty: true&lt;/code&gt; and no git data behind it. An existence check alone skips that
forever, and the backup that appears to be present is not there at all. Audit for
it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -sS -H &amp;quot;Authorization: token $TOKEN&amp;quot; &#92;
  &amp;quot;$API/repos/$OWNER/$NAME&amp;quot; | jq &#39;{empty, mirror, size}&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Backing up the backup&lt;/h2&gt;
&lt;p&gt;Metadata only: the database, &lt;code&gt;app.ini&lt;/code&gt;, and the SSH host keys. Repository content
is deliberately excluded, because every repository here is a mirror and the
recovery path is re-cloning from upstream rather than restoring:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/bin/bash
# Back up the Forgejo *metadata* only: the SQLite DB, app.ini and SSH host keys.
#
# Repository content is deliberately excluded. These repos are themselves
# mirrors of Codeberg/GitLab/GitHub, so re-cloning from upstream is the
# recovery path. That keeps each backup a few MB instead of many GB, which
# matters on a 23 GB disk.
#
# Restore: stop forgejo, untar over /var/lib/forgejo/data + /etc/forgejo,
# chown -R git:git /var/lib/forgejo, start forgejo.

set -euo pipefail

DEST=/var/lib/forgejo/backups
KEEP=4
DB=/var/lib/forgejo/data/forgejo.db

mkdir -p &amp;quot;$DEST&amp;quot;
chmod 700 &amp;quot;$DEST&amp;quot;

STAMP=$(date +%Y%m%d-%H%M%S)
OUT=&amp;quot;$DEST/forgejo-meta-$STAMP.tar.zst&amp;quot;
TMP=$(mktemp -d)
cleanup() { rm -rf &amp;quot;$TMP&amp;quot;; }
trap cleanup EXIT

# Consistent snapshot of a live, WAL-mode SQLite database.
if command -v sqlite3 &amp;gt;/dev/null 2&amp;gt;&amp;amp;1; then
    sqlite3 &amp;quot;file:${DB}?mode=ro&amp;quot; &amp;quot;.backup &#39;$TMP/forgejo.db&#39;&amp;quot;
else
    cp -a &amp;quot;$DB&amp;quot; &amp;quot;$TMP/&amp;quot; 2&amp;gt;/dev/null || true
    cp -a &amp;quot;${DB}-wal&amp;quot; &amp;quot;${DB}-shm&amp;quot; &amp;quot;$TMP/&amp;quot; 2&amp;gt;/dev/null || true
fi

cp -a /etc/forgejo/app.ini &amp;quot;$TMP/app.ini&amp;quot;
# Built-in SSH server host keys: preserving these avoids host-key warnings
# for anyone who has cloned over ssh://git@git.example.com:2222.
cp -a /var/lib/forgejo/data/ssh &amp;quot;$TMP/ssh&amp;quot; 2&amp;gt;/dev/null || true

tar -C &amp;quot;$TMP&amp;quot; -cf - . | zstd -q -19 -o &amp;quot;$OUT&amp;quot;
chmod 600 &amp;quot;$OUT&amp;quot;

# Prune all but the newest $KEEP archives.
ls -1t &amp;quot;$DEST&amp;quot;/forgejo-meta-*.tar.zst 2&amp;gt;/dev/null | tail -n +$((KEEP + 1)) | xargs -r rm -f

logger -t forgejo-backup &amp;quot;wrote $OUT ($(du -h &amp;quot;$OUT&amp;quot; | cut -f1))&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That produces tens of KB per archive instead of gigabytes. &lt;code&gt;.backup&lt;/code&gt; rather than
&lt;code&gt;cp&lt;/code&gt;, because the database is live and in WAL mode, and a plain copy gives a torn
snapshot with a stale &lt;code&gt;-wal&lt;/code&gt; sibling. Keeping the host keys means nobody sees a
host-key warning after a restore.&lt;/p&gt;
&lt;p&gt;Restoring is the DB plus one script run, since the mirror list lives in the
database and the rebuild-empty logic above re-clones the content:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo systemctl stop forgejo
# unpack, copy forgejo.db / app.ini / ssh into place, chown -R git:git
sudo systemctl start forgejo
sudo forgejo-add-mirrors all
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Watching the disk&lt;/h2&gt;
&lt;p&gt;Mirroring several accounts onto a small volume is exactly the kind of thing that
fills up quietly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/bin/bash
# Disk and memory guard for the Forgejo backup forge.
#
# This box has 23 GB total / ~16 GB free and 950 MiB RAM, so mirroring three
# accounts can plausibly fill it. Run hourly from cron; findings go to
# journald (tag forgejo-diskcheck) and to the status file below.
#
#   journalctl -t forgejo-diskcheck --since today
#   cat /var/lib/forgejo/diskcheck.status

set -uo pipefail

WARN_PCT=80
CRIT_PCT=90
REPO_DIR=/var/lib/forgejo/data/forgejo-repositories
STATUS_FILE=/var/lib/forgejo/diskcheck.status

say() {
    local level=&amp;quot;$1&amp;quot;; shift
    logger -t forgejo-diskcheck -p &amp;quot;daemon.${level}&amp;quot; -- &amp;quot;$*&amp;quot;
    printf &#39;%s [%s] %s&#92;n&#39; &amp;quot;$(date -Is)&amp;quot; &amp;quot;$level&amp;quot; &amp;quot;$*&amp;quot;
}

pct=$(df --output=pcent / | tail -1 | tr -dc &#39;0-9&#39;)
avail=$(df -h --output=avail / | tail -1 | tr -d &#39; &#39;)
repo_size=$(du -sh &amp;quot;$REPO_DIR&amp;quot; 2&amp;gt;/dev/null | cut -f1)
repo_count=$(find &amp;quot;$REPO_DIR&amp;quot; -maxdepth 2 -name &#39;*.git&#39; -type d 2&amp;gt;/dev/null | wc -l)
mem_avail=$(free -m | awk &#39;/^Mem:/ {print $7}&#39;)
swap_used=$(free -m | awk &#39;/^Swap:/ {print $3}&#39;)

{
    say info &amp;quot;disk ${pct}% used, ${avail} free | repos ${repo_count} using ${repo_size:-0} | mem ${mem_avail}MiB avail, swap ${swap_used}MiB used&amp;quot;

    if (( pct &amp;gt;= CRIT_PCT )); then
        say crit &amp;quot;CRITICAL: root filesystem ${pct}% full (${avail} left). Mirror syncs will start failing. Free space or resize the volume.&amp;quot;
        # Stop scheduling new mirror work so a half-written repo does not wedge
        # the disk completely. Existing data is untouched.
        if systemctl is-active --quiet forgejo; then
            say warning &amp;quot;leaving forgejo running; disable the update_mirrors cron in the admin UI if the disk does not recover&amp;quot;
        fi
    elif (( pct &amp;gt;= WARN_PCT )); then
        say warning &amp;quot;WARNING: root filesystem ${pct}% full (${avail} left).&amp;quot;
    fi

    if (( mem_avail &amp;lt; 80 )); then
        say warning &amp;quot;low memory: only ${mem_avail}MiB available, ${swap_used}MiB swap in use&amp;quot;
    fi

    # Largest repos, useful when deciding what to drop.
    if (( pct &amp;gt;= WARN_PCT )) &amp;amp;&amp;amp; [[ -d &amp;quot;$REPO_DIR&amp;quot; ]]; then
        say info &amp;quot;largest mirrors: $(du -s &amp;quot;$REPO_DIR&amp;quot;/*/* 2&amp;gt;/dev/null | sort -rn | head -5 | awk &#39;{printf &amp;quot;%s(%.0fMB) &amp;quot;, $2, $1/1024}&#39;)&amp;quot;
    fi
} &amp;gt; &amp;quot;$STATUS_FILE&amp;quot; 2&amp;gt;&amp;amp;1

exit 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And the cron that ties it together:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# Disk/memory guard: journalctl -t forgejo-diskcheck | /var/lib/forgejo/diskcheck.status
17 *  * * * root /usr/local/bin/forgejo-diskcheck &amp;gt;/dev/null 2&amp;gt;&amp;amp;1
# Metadata-only backup (DB + app.ini + host keys), keeps 4
23 3  * * 0 root /usr/local/bin/forgejo-backup-config &amp;gt;/dev/null 2&amp;gt;&amp;amp;1
# Refresh Cloudflare IP ranges for real-IP restoration
43 4  3 * * root /usr/local/bin/cloudflare-realip-update &amp;gt;/dev/null 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Verify from somewhere else&lt;/h2&gt;
&lt;p&gt;This is the other mistake worth confessing, because it invalidated everything I
thought I had checked. My first HTTPS test returned a clean &lt;code&gt;200&lt;/code&gt; and meant
nothing: my shell had &lt;code&gt;https_proxy&lt;/code&gt; set to a local SOCKS listener whose exit node
was &lt;em&gt;that same server&lt;/em&gt;, so the request hairpinned internally and never crossed the
public internet. The evidence was sitting in the access log the whole time, where
the client address was the server&#39;s own.&lt;/p&gt;
&lt;p&gt;If a reachability test shows the origin talking to itself, it has tested nothing.
Test from a third host:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;for p in 22 80 443 2222; do
  timeout 8 bash -c &amp;quot;exec 3&amp;lt;&amp;gt;/dev/tcp/$IP/$p&amp;quot; 2&amp;gt;/dev/null &#92;
    &amp;amp;&amp;amp; echo &amp;quot;port $p open&amp;quot; || echo &amp;quot;port $p blocked&amp;quot;
done

curl -s -o /dev/null -w &#39;%{http_code} tlsverify=%{ssl_verify_result}&#92;n&#39; &#92;
     https://git.example.com/
echo | openssl s_client -connect git.example.com:443 -servername git.example.com &#92;
     2&amp;gt;/dev/null | openssl x509 -noout -subject -dates
ssh-keyscan -p 2222 -T 10 $IP

git clone https://USER:TOKEN@git.example.com/USER/repo.git
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The last one is the only test that actually proves the thing works, so do not stop
before it.&lt;/p&gt;
&lt;h2&gt;Summary of the traps&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;symptom&lt;/th&gt;
&lt;th&gt;cause&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;restart loop, &lt;code&gt;save oauth2.JWT_SECRET failed: permission denied&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[oauth2] JWT_SECRET&lt;/code&gt; unset while &lt;code&gt;app.ini&lt;/code&gt; is not writable by the service account&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;works on loopback, unreachable from outside&lt;/td&gt;
&lt;td&gt;a second nftables chain on the same hook with &lt;code&gt;policy drop&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;firewall rule present but 0 packets&lt;/td&gt;
&lt;td&gt;that rule is not in the path, something earlier drops&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;reachability test passes suspiciously easily&lt;/td&gt;
&lt;td&gt;proxy hairpin, check the client IP in the access log&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;migrate fails, error names your username as a token&lt;/td&gt;
&lt;td&gt;&lt;code&gt;auth_username&lt;/code&gt; sent alongside &lt;code&gt;auth_token&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;org repositories fail with &lt;code&gt;required scope(s)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;token lacks &lt;code&gt;write:organization&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;large clone dies with &lt;code&gt;curl 92 ... stream CANCEL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HTTP/2, pin &lt;code&gt;http.version = HTTP/1.1&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;unable to rename temporary &#39;*.pack&#39; file&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;secondary symptom of the above, cleanup raced the dying clone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;repository exists locally with no commits&lt;/td&gt;
&lt;td&gt;failed migration left &lt;code&gt;empty: true&lt;/code&gt;, delete and recreate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ssh-keygen&lt;/code&gt; as the service account fails&lt;/td&gt;
&lt;td&gt;parent directory created by root, &lt;code&gt;chown&lt;/code&gt; it first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;certificate renews but the old one is still served&lt;/td&gt;
&lt;td&gt;&lt;code&gt;certonly&lt;/code&gt; installs no reload hook&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
</content>
  </entry>
  <entry>
    <title>MATLAB</title>
    <link href="https://zhengnanli.gitlab.io/blog/matlab/"/>
    <updated>2026-07-30T00:00:00.000Z</updated>
    <published>2025-04-15T00:00:00.000Z</published>
    <id>https://zhengnanli.gitlab.io/blog/matlab/</id>
    <summary>Two MATLAB releases, two different glibc crashes on Arch, both diagnosed by reading the crash rather than guessing. Includes the R2026a WebGL fallback fix.</summary>
    <content type="html">&lt;p&gt;&amp;quot;The glibc problem&amp;quot; on &lt;code&gt;arch&lt;/code&gt; + &lt;code&gt;MATLAB&lt;/code&gt; isn&#39;t one bug — it&#39;s a category. Two different releases, two different root causes, found by actually reading the crash instead of guessing.&lt;/p&gt;
&lt;h2&gt;&lt;code&gt;glibc 2.41&lt;/code&gt; executable-stack bug&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;glibc 2.41&lt;/code&gt; stopped honoring a library&#39;s request for an executable stack via &lt;code&gt;dlopen&lt;/code&gt;/&lt;code&gt;dlmopen&lt;/code&gt;. Some of MATLAB&#39;s bundled &lt;code&gt;.so&lt;/code&gt; files ask for exactly that, so they segfault on load. Clear the flag with &lt;code&gt;patchelf&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo patchelf --clear-execstack ~/.MathWorks/ServiceHost/-mw_shared_installs/v2025.1.1.2/bin/glnxa64/libmwfoundation_crash_handling.so
sudo patchelf --clear-execstack ~/.MathWorks/ServiceHost/-mw_shared_installs/v2025.1.1.2/bin/glnxa64/mathworksservicehost/rcf/matlabconnector/serviceprocess/rcf/service/libmwmshrcfservice.so
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Check whether a given install even has the problem before chasing this fix — later releases (R2026a) ship with zero executable-stack libraries, MathWorks fixed it upstream:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;find &amp;quot;$MATLAB_ROOT&amp;quot; -name &#39;*.so&#39; -print0 &#92;
  | xargs -0 -I{} sh -c &#39;readelf -lW &amp;quot;{}&amp;quot; | grep -q &amp;quot;GNU_STACK.*RWE&amp;quot; &amp;amp;&amp;amp; echo {}&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;&lt;code&gt;gnutls&lt;/code&gt; ABI break (R2026a activation)&lt;/h2&gt;
&lt;p&gt;Fresh R2026a install: the activation window never appears, no error, process just dies. Not the exec-stack bug above — confirmed with a real backtrace instead of assuming:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;gdb -q -batch -ex run -ex bt --args &#92;
  &amp;quot;$MATLAB_ROOT/bin/glnxa64/MathWorksProductAuthorizer&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Crash is inside &lt;code&gt;libgnutls.so.30&lt;/code&gt;, called from &lt;code&gt;lc_init()&lt;/code&gt; in &lt;code&gt;libmwinstall_activationwsclientimpl.so&lt;/code&gt;. MATLAB&#39;s activation/licensing plugin framework &lt;code&gt;dlopen()&lt;/code&gt;s the &lt;strong&gt;system&lt;/strong&gt; gnutls at runtime, and Arch&#39;s current gnutls is ABI-incompatible with what the client expects.&lt;/p&gt;
&lt;p&gt;Fix: stage a pinned, private gnutls + nettle build and point &lt;code&gt;LD_LIBRARY_PATH&lt;/code&gt; at it for MATLAB&#39;s own processes — no need to touch the system gnutls or the MATLAB install itself. Version pairing matters: gnutls &lt;code&gt;3.8.9-1&lt;/code&gt; needs a &lt;code&gt;libhogweed&lt;/code&gt; exporting &lt;code&gt;nettle_rsa_oaep_sha384_decrypt&lt;/code&gt; under symbol version &lt;code&gt;HOGWEED_6&lt;/code&gt;; nettle &lt;code&gt;3.9.1&lt;/code&gt; is missing that symbol, nettle &lt;code&gt;3.10-1&lt;/code&gt; has it under the same soname.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;mkdir -p ~/.local/lib/matlab-gnutls-compat
cd /tmp &amp;amp;&amp;amp; mkdir gnutls-old nettle-310

curl -sL -o gnutls-old/g.pkg.tar.zst &#92;
  https://archive.archlinux.org/packages/g/gnutls/gnutls-3.8.9-1-x86_64.pkg.tar.zst
tar --zstd -xf gnutls-old/g.pkg.tar.zst -C gnutls-old

curl -sL -o nettle-310/n.pkg.tar.zst &#92;
  https://archive.archlinux.org/packages/n/nettle/nettle-3.10-1-x86_64.pkg.tar.zst
tar --zstd -xf nettle-310/n.pkg.tar.zst -C nettle-310

cp -a gnutls-old/usr/lib/libgnutls* ~/.local/lib/matlab-gnutls-compat/
cp -a nettle-310/usr/lib/libnettle* nettle-310/usr/lib/libhogweed* &#92;
  ~/.local/lib/matlab-gnutls-compat/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then launch MATLAB with that on &lt;code&gt;LD_LIBRARY_PATH&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;env LD_LIBRARY_PATH=~/.local/lib/matlab-gnutls-compat &#92;
  &amp;quot;$MATLAB_ROOT/bin/matlab&amp;quot; -nodesktop -nosplash -nosoftwareopengl
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To pop the activation window on its own — e.g. to (re)activate without launching the full app — run the authorizer client directly with the same &lt;code&gt;LD_LIBRARY_PATH&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;env LD_LIBRARY_PATH=~/.local/lib/matlab-gnutls-compat &#92;
  &amp;quot;$MATLAB_ROOT/bin/glnxa64/MathWorksProductAuthorizer.sh&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Watch out for stale &lt;code&gt;LD_PRELOAD&lt;/code&gt;s from older fixes lying around in shell config (&lt;code&gt;LD_PRELOAD=/usr/lib/libstdc++.so&lt;/code&gt; forcing the system C++ runtime over MATLAB&#39;s bundled one is its own separate, documented crash cause) — they&#39;re easy to leave behind across MATLAB version upgrades and mask what&#39;s actually going on.&lt;/p&gt;
&lt;h2&gt;&amp;quot;Graphics acceleration hardware is unavailable&amp;quot; (R2026a)&lt;/h2&gt;
&lt;p&gt;Same bundled-runtime theme as above, different victim. R2026a no longer draws figures with desktop &lt;code&gt;OpenGL&lt;/code&gt;; it renders &lt;code&gt;WebGL&lt;/code&gt; inside an embedded &lt;code&gt;Chromium&lt;/code&gt; (&lt;code&gt;MATLABWindow&lt;/code&gt; + &lt;code&gt;libcef.so&lt;/code&gt;) through &lt;code&gt;ANGLE&lt;/code&gt;. So the fallback is no longer MATLAB&#39;s own software &lt;code&gt;OpenGL&lt;/code&gt;, it&#39;s &lt;code&gt;SwiftShader&lt;/code&gt;, and on &lt;code&gt;arch&lt;/code&gt; you land there by default.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;opengl(&#39;data&#39;)&lt;/code&gt; is gone in R2026a. The replacement is &lt;code&gt;rendererinfo&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-matlab&quot;&gt;r = rendererinfo;
disp(r.RendererDevice)
disp(r.Details.HardwareSupportLevel)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Broken looks like this, on a &lt;code&gt;Radeon 780M&lt;/code&gt; that has a perfectly good &lt;code&gt;amdgpu&lt;/code&gt; + &lt;code&gt;mesa&lt;/code&gt; stack:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Root cause&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;ANGLE&lt;/code&gt; picks its &lt;code&gt;Vulkan&lt;/code&gt; backend, asks the loader to enumerate devices, and the only &lt;code&gt;ICD&lt;/code&gt; that answers is the &lt;code&gt;SwiftShader&lt;/code&gt; one MATLAB ships (&lt;code&gt;bin/glnxa64/vk_swiftshader_icd.json&lt;/code&gt;). The real driver never loads. Reproduce the failure directly, outside MATLAB, with a five-line &lt;code&gt;dlopen&lt;/code&gt; harness:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;#include &amp;lt;dlfcn.h&amp;gt;
#include &amp;lt;stdio.h&amp;gt;
int main(int c, char **v) {
  void *h = dlopen(v[1], RTLD_NOW);
  printf(&amp;quot;%s&#92;n&amp;quot;, h ? &amp;quot;OK&amp;quot; : dlerror());
  return !h;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;gcc -o dlopentest dlopentest.c
./dlopentest /usr/lib/libvulkan_radeon.so                      # OK
LD_LIBRARY_PATH=$MATLAB_ROOT/sys/os/glnxa64 &#92;
  ./dlopentest /usr/lib/libvulkan_radeon.so
# .../sys/os/glnxa64/libstdc++.so.6: version `GLIBCXX_3.4.32&#39; not found
#   (required by /usr/lib/libSPIRV-Tools.so)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There it is. &lt;code&gt;radv&lt;/code&gt; links &lt;code&gt;libSPIRV-Tools&lt;/code&gt;, which is built against current &lt;code&gt;mesa&lt;/code&gt;&#39;s toolchain and needs &lt;code&gt;GLIBCXX_3.4.32&lt;/code&gt;. MATLAB puts its bundled &lt;code&gt;libstdc++.so.6.0.30&lt;/code&gt; (GCC 12.3) ahead of the system &lt;code&gt;6.0.36&lt;/code&gt;, so &lt;code&gt;radv&lt;/code&gt; fails to load, &lt;code&gt;Vulkan&lt;/code&gt; enumeration comes back with &lt;code&gt;SwiftShader&lt;/code&gt; only, and MATLAB dutifully warns that hardware acceleration is unavailable. The GPU is fine; the C++ runtime under it is too old.&lt;/p&gt;
&lt;h3&gt;Why &lt;code&gt;LD_LIBRARY_PATH&lt;/code&gt; cannot fix this&lt;/h3&gt;
&lt;p&gt;The obvious move is to prepend a directory holding a symlink to the system &lt;code&gt;libstdc++&lt;/code&gt;. The &lt;code&gt;matlab&lt;/code&gt; launcher even documents a variable for it, &lt;code&gt;LDPATH_PREFIX&lt;/code&gt;, which lands at the very front of &lt;code&gt;LD_LIBRARY_PATH&lt;/code&gt;. It changes nothing, and the reason is worth knowing:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;readelf -d &amp;quot;$MATLAB_ROOT/bin/glnxa64/MATLABWindow&amp;quot; | grep -E &#39;RPATH|RUNPATH&#39;
# 0x000000000000000f (RPATH) Library rpath: [$ORIGIN:$ORIGIN/../../sys/os/glnxa64]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;DT_RPATH&lt;/code&gt;, not &lt;code&gt;DT_RUNPATH&lt;/code&gt;. The dynamic linker searches &lt;code&gt;DT_RPATH&lt;/code&gt; &lt;strong&gt;before&lt;/strong&gt; &lt;code&gt;LD_LIBRARY_PATH&lt;/code&gt;, so no amount of path ordering wins. Confirm what a live process actually mapped rather than what you hoped it would:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;grep -oE &#39;/[^ ]*libstdc&#92;+&#92;+[^ ]*&#39; /proc/$(pgrep -f MATLABWindow | head -1)/maps
# /usr/local/MATLAB/R2026a/sys/os/glnxa64/libstdc++.so.6.0.30
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;LD_PRELOAD&lt;/code&gt; is loaded before anything the &lt;code&gt;RPATH&lt;/code&gt; can reach, so that is the lever that works.&lt;/p&gt;
&lt;h3&gt;Fix&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;env LD_PRELOAD=/usr/lib/libstdc++.so.6 &amp;quot;$MATLAB_ROOT/bin/matlab&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Newer &lt;code&gt;libstdc++&lt;/code&gt; keeps the old symbol versions, so MATLAB&#39;s own GCC-12-era libraries are satisfied by &lt;code&gt;6.0.36&lt;/code&gt; too. The launcher appends to &lt;code&gt;LD_PRELOAD&lt;/code&gt; (&lt;code&gt;LD_PRELOAD=&amp;quot;${LD_PRELOAD:+${LD_PRELOAD}:}...&amp;quot;&lt;/code&gt;) instead of clobbering it, so an inherited value survives alongside MATLAB&#39;s own shims. Result:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ANGLE (AMD, AMD Radeon 780M Graphics (radeonsi phoenix ACO), OpenGL 4.6)
HardwareSupportLevel: Full
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note this is the &lt;em&gt;narrow, versioned&lt;/em&gt; &lt;code&gt;libstdc++.so.6&lt;/code&gt;, not the &lt;code&gt;libstdc++.so&lt;/code&gt; linker script blamed for crashes at the end of the previous section. Same family of hack, and it&#39;s still the first thing to rip out when MATLAB later misbehaves for unrelated reasons.&lt;/p&gt;
&lt;p&gt;If you&#39;d rather not carry the variable, MATLAB&#39;s bundled &lt;code&gt;libstdc++.so.6&lt;/code&gt; is already just a symlink and the pristine copies live in &lt;code&gt;sys/os/glnxa64/orig/&lt;/code&gt;, so repointing it at &lt;code&gt;/usr/lib/libstdc++.so.6&lt;/code&gt; does the same thing install-wide, at the cost of a change a MATLAB update can quietly revert.&lt;/p&gt;
&lt;h3&gt;Two dead ends worth naming&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;strings&lt;/code&gt; over the &lt;code&gt;CEF&lt;/code&gt; glue libraries turns up &lt;code&gt;MW_ENABLE_GPU_RENDERING&lt;/code&gt; sitting next to &lt;code&gt;disable-gpu&lt;/code&gt; and &lt;code&gt;enable-unsafe-swiftshader&lt;/code&gt; in &lt;code&gt;libmwcef_common.so&lt;/code&gt;, which looks exactly like the switch you want. It isn&#39;t; setting it made no difference, because the GPU was never being blocklisted, just left driverless. &lt;code&gt;bin/glnxa64/gpu_info&lt;/code&gt; is likewise a red herring that reports the right device (&lt;code&gt;0x1002:0x1900&lt;/code&gt;) with driver version &lt;code&gt;0.0.0.0&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Also, &lt;code&gt;-nosoftwareopengl&lt;/code&gt; is now inert: R2026a answers with &lt;code&gt;Warning: OpenGL Startup options have been removed.&lt;/code&gt; Drop it from launcher aliases.&lt;/p&gt;
&lt;h3&gt;Verify&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;rendererinfo&lt;/code&gt; alone can lie by omission, so render something that leans on the GPU (lighting plus transparency) and confirm it survives:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-matlab&quot;&gt;f = figure(&#39;Visible&#39;,&#39;off&#39;);
[X,Y,Z] = peaks(120);
surf(X,Y,Z,&#39;EdgeColor&#39;,&#39;none&#39;); camlight; lighting gouraud; alpha 0.7
exportgraphics(f, &#39;hwtest.png&#39;, &#39;Resolution&#39;, 150)
&lt;/code&gt;&lt;/pre&gt;
</content>
  </entry>
  <entry>
    <title>nixos</title>
    <link href="https://zhengnanli.gitlab.io/blog/nixos/"/>
    <updated>2025-01-10T00:00:00.000Z</updated>
    <published>2025-01-10T00:00:00.000Z</published>
    <id>https://zhengnanli.gitlab.io/blog/nixos/</id>
    <summary>A short reference of the NixOS store, garbage collection and rebuild commands worth keeping to hand.</summary>
    <content type="html">&lt;p&gt;A few &lt;kbd&gt;key&lt;/kbd&gt; commands:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;nix-store --verify --check-contents --repair
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;nix-collect-garbage -d
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;nixos-rebuild switch
&lt;/code&gt;&lt;/pre&gt;
</content>
  </entry>
  <entry>
    <title>Networking in linux</title>
    <link href="https://zhengnanli.gitlab.io/blog/network/"/>
    <updated>2025-01-10T00:00:00.000Z</updated>
    <published>2025-01-10T00:00:00.000Z</published>
    <id>https://zhengnanli.gitlab.io/blog/network/</id>
    <summary>WireGuard, GlobalProtect and eduroam 802.1X connections set up from the command line with nmcli.</summary>
    <content type="html">&lt;h2&gt;&lt;code&gt;nmcli&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;To import:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;nmcli connection import type wireguard file school.conf
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To activate/deactivate:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;nmcli connection up school / nmcli connection down school
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To disable autoconnect:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;nmcli connection modify school connection.autoconnect no
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;&lt;code&gt;GlobalProtect&lt;/code&gt;, using Northeastern as an example.&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;nmcli connection add connection.id school &#92;
connection.type vpn vpn.service-type openconnect &#92;
vpn.data cookie-flags=1,gateway=vpn.northeastern.edu,protocol=gp &#92;
vpn.secrets gateway=vpn.northeastern.edu,gwcert=
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;&lt;code&gt;eduroam&lt;/code&gt; and &lt;code&gt;802-1x&lt;/code&gt; authentication&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;nmcli con add &#92;
  type wifi &#92;
  ifname wlp3s0 &#92;
  con-name eduroam &#92;
  ssid eduroam &#92;
  ipv4.method auto &#92;
  802-1x.eap peap &#92;
  802-1x.phase2-auth mschapv2 &#92;
  802-1x.identity &amp;quot;&amp;lt;your ldap username&amp;gt;&amp;quot; &#92;
  802-1x.password &amp;quot;&amp;lt;your ldap password&amp;gt;&amp;quot; &#92;
  wifi-sec.key-mgmt wpa-eap
&lt;/code&gt;&lt;/pre&gt;
</content>
  </entry>
</feed>
