The test read the working tree's mode, which follows the umask of
whoever checked it out. CI's runner checks out under 0077, so the
script stood there as 0700 and the test, asking for every execute
bit, failed though git keeps it as 100755. It now asks git, which is
what the host checks out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Rests the heavy sidecars, runs every queued job, then stops the runner and
starts the sidecars again however it ends; from melchior through
systemd-run, so a closed laptop doesn't end it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
A reboot or a power cut between 01:55 and 05:00 left them stopped until the
next morning: halkyon-morning.timer now also fires two minutes after boot.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
first-start.md takes the instance from a bare host to running, once: the
sidecars' users, deploy with a read-only key to the forge and the
developer's keys (GPG for redeploy.sh's check, SSH pinned to it), .env,
SimpleLogin's app, the sidecars' folders and Jellyfin moved in, a role and
database for each app's server, the first start, the first operator by
ADR 0021's SQL, and the backups.
halkyon-li-env.sh writes .env with every secret made on the host and never
over an existing file, as the peppers must not change once people sign in:
SimpleLogin alone with approval, everything running behind the forward,
Train's map and routes over southern Bavaria and its search over Germany
until the Instance console cuts regions, Germany as where data and backups
are.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Ubuntu 26.04 builds its boot images with dracut, and dropbear-initramfs needs
initramfs-tools, which replaces it. The host's step 2 now installs
initramfs-tools with cryptsetup-initramfs beside dropbear, sets the boot
image's address in a conf.d file of its own, and checks every new image for
what opens the disk, dropbear, its key and the adapter's driver before any
reboot. Before switching, dracut's image of the running kernel is kept as an
entry in GRUB's menu (shown 5 s, the boot in text), so a failed image is
undone at the iMac without a USB stick.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Ubuntu 26.04 confines wg-quick in an AppArmor profile whose nft sub-profile
may read nothing in /etc/wireguard, so the tunnel failed to come up at home.
The unit's drop-in (wg-quick-halkyon.conf, before-docker.conf before) loads
the rules before the interface exists and deletes them after it is gone, so
the tunnel never runs without them. ramiel, on 24.04, keeps PostUp.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
With https_port 8443, Caddy's automatic HTTP-to-HTTPS redirects would send
visitors of code.halkyon.app, halkyon.app and learn.halkyon.app to :8443,
which is closed from outside. A site of their own on http:// redirects them
to the default port instead. The README's step for home's key points at the
host's step 5, and checks that 8443 stays closed and the redirect carries no
port.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The CI runner moves to halkyon.li's host at home and runs from 02:00 to
05:00 alone: a timer starts it, its unit's RuntimeMaxSec ends it, and
night.sh rests the instance's heavy sidecars around it as deploy, while
Caddy, PostgreSQL and the apps' servers keep serving. Jobs pushed during the
day wait in Forgejo's queue.
Its sandbox fits the new host: its folder on the HDD, the rest of /data
hidden, containerd's store out of reach, no private IPv4 network and no
IPv6 at all (the home network's IPv6 addresses are public ones under a
changing prefix, on the internet's own interface), 5 GB of memory with swap
beyond. The Sandbox step checks the tunnel, the home router and IPv6.
BREAKING CHANGE: CI deploys nothing any more: the deploy job goes with
learn.halkyon.eu's server, and halkyon.li deploys by hand with redeploy.sh
once a commit's night's run passed. Jobs run on the runner labelled
balthasar; sahaquiel's runner picks up none.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The Jellyfin sidecar takes its library from a folder on the host where .env
names one (JELLYFIN_MEDIA, read-only), else from deploy/jellyfin/media as
before. On halkyon.li that is /data/media: the library moves out of the
developer's home, which Jellyfin's own user can't enter. The sidecar README
says how the home end's configuration, accounts and libraries move in, with
its network settings corrected for Caddy before it starts.
BREAKING CHANGE: compose.jellyfin-home.yaml, deploy/home-jellyfin/ and
jellyfin.caddy's JELLYFIN_UPSTREAM are gone with ADR 0032, whose server end
retires with learn.halkyon.eu's server; it was never brought up.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Meditate's deploy step (ADR 0038, step 7; ADR 0039, D14), as Todo's: the
server's Dockerfile with the library's check compiled beside it, its
production config on its own database and role, compose.meditate.yaml as a
member of the register, its site sending /library/* to the web server and
the rest to the API server, the operator's library read-only in a compose
file of its own, and redeploy.sh, backup.sh and the image replay taking it
in.
Every server's public address now comes from .env (Serverpod's
SERVERPOD_API_SERVER_PUBLIC_HOST and _WEB_SERVER_, which win over the
config file): Train, Todo and Meditate from their domains, Learn from
LEARN_PUBLIC_HOST, unset learn.halkyon.eu as before. Without it a server on
halkyon.li would name halkyon.eu in its SimpleLogin callback, profile pages
and privacy page.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
ramiel's HAProxy passes halkyon.li's TLS connections, unopened, through a
WireGuard tunnel to the instance at home, with the visitor's address in a
PROXY header, and everything else to ramiel's own Caddy, moved to the
loopback. Each end's nftables table lets the tunnel carry HAProxy's 443 and
the SSH jump's 22 alone, and nothing from home to ramiel.
The host at home: sshd with keys alone, the root disk unlocked over SSH at
boot (dropbear-initramfs, D20), Docker's and containerd's data bind-mounted
from the HDD, and home's end of the tunnel before Docker. An instance behind
the forward (compose.forwarded.yaml) publishes 443 on the tunnel's address
alone, trusts the PROXY header from ramiel alone, and gets its certificates
through TLS-ALPN, without HTTP/3.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Only the deploy builds the images, on the server, after every check
has passed, so an image that pub refuses cost a push each time (twice
on 4 Nov 2026). A step of tool/check.dart, right after the packages,
replays each Dockerfile's build stage up to its `dart pub get` from
the working tree's files in an empty folder, with Dart alone at the
image's version (downloaded once into ~/.cache/halkyon-check/, its
SHA-256 checked), offline, in an environment holding nothing of the
machine's Dart or Flutter: `dart run` from Flutter sets DART_ROOT,
through which pub found Flutter and accepted what the image refuses.
Checked on both failed deploys' states, which fail it as they did,
and on the fix, which passes. --images-only runs it alone, in
seconds. The list of images, dartImages, moves into
tool/src/images.dart, and a test keeps every Dockerfile building with
Dart on it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
deploy/compose.todo.yaml adds Todo's server at todo.halkyon.eu, a member
of Learn's register, its database halkyon_todo in the instance's
PostgreSQL, its image built as Train's. redeploy.sh and backup.sh go over
every app server .env switches on (train, todo). Each compose file names
its server to the register in a variable of its own
(INSTANCE_MEMBER_TRAIN, INSTANCE_MEMBER_TODO), which halkyon_base adds to
INSTANCE_MEMBERS, each address once, as a second compose file's default
would have replaced Train's.
apps/todo/release.dart configures the release tool for Todo's APK and
snap: a key of its own (pinned once made; until then nothing is
released), the F-Droid listing (Time, NonFreeNet), and the snap from
apps/todo/snap/, which the tool builds in a clone of the repository at the
tag with that folder in place of snap/, as snapcraft builds one recipe a
folder and the root's is Learn's. What's new for 0.1.0 in English and
German, now shown. Android's backup and transfer are off, as Train's
(D53): the reminders scheduled keep their tasks' titles unsealed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
A tunnel that fails to come up takes Jellyfin's internet with it, as
linuxserver/wireguard's client mode deletes the default route. Named by
learn.halkyon.eu, the endpoint needed DNS to come up, which the home end
starting or waking hasn't always got yet; by its address the tunnel comes
up whatever the network does. Closes ADR 0032's open point.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The instance serves jellyfin.halkyon.eu and its Caddy proxies through a
WireGuard tunnel to a Jellyfin on the operator's own machine, which dials
out, so nothing names the home network's address and its router opens no
port. compose.jellyfin-home.yaml takes the sidecar's place, with its
site's rules unchanged (jellyfin.caddy's upstream is now a setting), and
leaves JELLYFIN_URL unset, so the instance suggests it only while it
answers.
On the instance's server the tunnel reaches Jellyfin from Caddy alone and
nothing of the host (wg-jellyfin-rules.sh, set and removed with the
tunnel); the CI runner's unit denies it, and CI's Sandbox step fails if a
job reaches it. The home end keeps Jellyfin in WireGuard's network
namespace, its port on the loopback alone. The steps are in
deploy/sidecars/README.md, the rules held by test/deploy/jellyfin_home_test.dart.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
A `search` endpoint asks the instance's Photon (compose.search.yaml, Europe's
database from GraphHopper, renewed monthly) for people signed in, limited and
unlogged. The planner gets a field over its map: the places found nearest the
map's centre first, in the app's language; one chosen is the next waypoint.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Protomaps' basemap cut for Europe and served by pmtiles beside Train's server
(compose.maps.yaml), drawn by Train's own style, light and dark, which the
server serves. Only people signed in get tiles: map.source hands a key that
Caddy has the server check before each tile; nothing logs a tile.
Also fixes .dockerignore, which left apps/train/ out of the build context, so
Train's server's and BRouter's images couldn't have been built.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The deploy of 46be9983 failed while building the server's image: the
Dockerfile copies halkyon_core since ADR 0021's step 1 (a060fbc2), but
.dockerignore still left it out of the build's context, and halkyon_llm,
which the module forwards the operator's assistants with since step 2,
was never copied at all. Nothing was switched: the instance kept running
v0.20.0's server.
CI never builds the images (its runner has no Docker, by design), so
test/deploy/images_test.dart now holds each Dockerfile to its package's
path dependencies, dev dependencies included as pub resolves them: every
one copied, kept in the narrowed workspace, and not left out by
.dockerignore. It failed on both before this fix. Built locally since:
the server's image builds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
SimpleLogin takes only https redirect addresses, each registered exactly,
so the instance's SimpleLogin app registers one page on the register's
web server, /simplelogin-callback, which sends the browser on to the app
that began the sign-in: a 303 to the desktop's loopback (127.0.0.1, a port
per app) or the phone's scheme, chosen by the marker the state begins with
(`<app>-<platform>.<rest>`, the rest 1 to 512 characters of base64url),
from a fixed table, so it sends nobody anywhere else. It forwards the code
or SimpleLogin's error (`deny`) with the state and nothing else, refuses
anything malformed, repeated or not a GET with a page that repeats nothing
it was given, and keeps and logs nothing (no-store, no referrer). It is
built ahead of the sign-in itself so development can sign in through
learn.halkyon.eu's page, which the development app registers too.
Android's assetlinks.json for Halkyon Learn signed with the release key
is answered by the Caddyfile on learn.halkyon.eu alone: the one domain the
project's builds name, and the server knows no app's key. A root test
holds its fingerprint to tool/release/android-certificate.sha256. The
app's intent filter comes with the sign-in; iOS's association waits for
an Apple team ID.
The profile page's language choice and look move to common/web_page.dart,
shared by both pages.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>