dev_gateway #22

Merged
robert merged 14 commits from dev_gateway into main 2026-10-07 16:34:47 +02:00
107 changed files with 4142 additions and 507 deletions
Showing only changes of commit 2f7a02f1d1 - Show all commits

View file

@ -12,6 +12,7 @@ on:
- '**/*.md' - '**/*.md'
- 'docs/**' - 'docs/**'
- 'website/**' - 'website/**'
- 'ops/**'
- 'CHANGELOG.md' - 'CHANGELOG.md'
- 'RELEASING.md' - 'RELEASING.md'
workflow_dispatch: workflow_dispatch:

View file

@ -57,3 +57,13 @@ jobs:
env: env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_RELEASE_TOKEN }} FORGEJO_TOKEN: ${{ secrets.FORGEJO_RELEASE_TOKEN }}
run: ./scripts/publish-release.sh "${GITHUB_REF_NAME}" run: ./scripts/publish-release.sh "${GITHUB_REF_NAME}"
- name: Refresh furtka.org download page
# The download page reads the latest release (ISO, size, date,
# checksum) from the Forgejo API at build time, so rebuild the site
# now that the release exists. Same runner + bind mounts as
# deploy-site.yml. A failure here doesn't touch the release.
continue-on-error: true
run: |
apk add --no-cache hugo rsync
./website/deploy-ci.sh

View file

@ -7,6 +7,148 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
## [Unreleased] ## [Unreleased]
### Added
- **New logo: Mark M** (a lowercase *f* cut out of an arched gate), winner of
the furtka.org vote (29 of 42 votes). It is now in the website header,
favicon and share card, the installer header and tab icon, the on-box UI
(every page and the login), the console welcome screens of the live ISO
and the installed box, and the BIOS boot-menu splash. Brand files in
`docs/brand/`, generated by `website/tools/brand-assets.py`.
- **furtka.org/download/** (and `/de/download/`): ISO download with size,
date and checksum, requirements and step-by-step install guide. Releases
now publish `furtka-<ver>.iso.sha256` and refresh the site.
### Security
- **App names are validated everywhere.** `POST /api/apps/remove` with
`{"name": ".."}` or `"/"` sent `shutil.rmtree` at `/var/lib/furtka` or the
root filesystem (and wiping `users.json` reopened first-run admin setup to
the LAN). Names from URLs, JSON bodies and manifests must now match
`^[a-z][a-z0-9_-]*$`; `installer.remove`/`update_env` check it too.
- **Settings can't inject into `.env`.** Line breaks are refused; values
with `$` are single-quoted so compose doesn't expand them (`pa$word` used
to become `pa`). Validation runs before the write, so a rejected `/etc`
path is no longer saved anyway.
- **Damaged `users.json` fails closed.** An empty/corrupt file used to read
as "no admin yet" and turned `/login` into an open create-admin form.
`/login` now answers 503 with SSH recovery steps; `save_users` fsyncs.
- **API server hardening:** threaded (state changes still serialised), 1 MiB
body cap, 30 s socket timeout, cross-site POSTs refused via
`Sec-Fetch-Site`, JSON 500 instead of dropped connections.
### Fixed
- **Installer disk picker:** SATA SSDs were labelled "HDD" and lost the
recommendation to bigger spinning disks; type now comes from lsblk
`TRAN`/`ROTA`. zram, eMMC boot/RPMB areas, read-only and 0-byte disks are
hidden; disks under 16 GB are greyed out; USB disks rank last; >2 TB disks
on BIOS machines get an MBR warning; the boot stick is also found via the
archiso kernel args when `bootmnt` is gone. Step 2 only accepts a listed disk.
- **Installer runs one install at a time** (double-click / second browser
started a second archinstall on the same disk), and a crashed archinstall
(OOM kill, signal) now shows as an error instead of a progress bar stuck
forever. Disk-probe exceptions land in the log instead of a bare 500.
- **Failed installs can be retried** from the error page without re-entering
everything.
- **No 2-minute boot stall on boards with a second NIC:** the target gets a
`systemd-networkd-wait-online --any` drop-in.
- **Self-update:** symlink flip is atomic; any error after the flip rolls
back (and restores the previous Caddyfile); health check survives read
timeouts; every failure ends in an `error` stage the settings page shows.
- **Interrupted app installs no longer block all later installs** — a stale
state with no live runner is marked failed after 60 s.
- **Catalog sync / update checks** no longer strand on socket resets or disk
full.
- **Apps page after an API restart** redirects to `/login` instead of
showing an empty app list.
- **Self-update starts newly added timers immediately and links units
through `current`.** `_link_new_units()` enabled a timer a release
introduced but never started it, so after the 26.19 → 26.20 update the
bench sat with `furtka-welcome.timer` `inactive (dead)` — and the console
banner without its IP line — until the next reboot. It also linked the
unit from `/opt/furtka/versions/<ver>/…` instead of `/opt/furtka/current/…`,
so the link would have stopped following later updates and rollbacks
(installer-created links use `current`). Now: link via `current`, enable,
start.
### Changed
- Wizard wording: "Server name" with a live `http://<name>.local` preview,
"Pick a disk" with an explicit erase warning, readable confirm table,
password hint explains it's also the SSH/console password, names are
lowercased instead of rejected, system account names are refused.
- Live console banner says a network cable with internet is required.
## [26.20-alpha] - 2026-08-25
First release shaped by a real-hardware install (old laptop mainboard, BIOS boot,
SSD with a previous Proxmox VE on it). All four fixes are things an empty
Proxmox VM never shows.
### Fixed
- **First catalog sync no longer dies silently at boot.** On dual-stack
LANs `network-online.target` is satisfied by the IPv6 SLAAC address a
second before DHCPv4 lands, so `furtka-catalog-sync.service` ran into
`Network is unreachable`, exited, and the next attempt was the daily timer
— up to 6 h of jitter later. Meanwhile `catalog-state.json` stayed on
`checking` and the apps page showed "checking…" with nothing running
(first hardware bench, 2026-08-25). The service now retries on failure
(every 20 s, up to 8 times in 15 min), a failed sync is recorded as stage
`error` with the message, and the apps page shows "last sync failed: …"
instead of a fake in-progress state.
- **Installer frees the target disk before partitioning.** The live ISO
auto-activates whatever an attached drive carries (LVM volume groups, md
arrays, swap) — the first hardware bench's SSD still had a Proxmox VE
`pve` VG on it — and archinstall then failed at 12 % with `unable to
inform the kernel of the change, probably because it/they are in use`.
`install_run` now runs `diskprep.release_disk()` first: swapoff/umount
leaves-first, `dmsetup remove` / `mdadm --stop` for every dm/md node
stacked on the disk, `wipefs -a` on partitions + disk, then
`blockdev --rereadpt`. Every step is written to the top of the install
log. The progress page also names this failure explicitly instead of a
bare "hit a snag" if it does still happen.
- **Console banner always shows the IP fallback.** On both the live ISO and
the installed system the `/etc/issue` welcome was written once, after
`network-online.target`, and simply omitted the `http://<ip>` line when no
address existed yet — so on a slow-DHCP NIC (first hardware bench,
2026-08-24) the screen only ever offered `proksi.local`. The banner script
now runs from a timer (`furtka-issue.timer` on the ISO,
`furtka-welcome.timer` on the installed box, every 5 s) and always prints
a fallback line: every global IPv4 address once there is one, or a
"no IP address yet — check the cable / DHCP" hint until then. It only
rewrites `/etc/issue` and nudges agetty when the text changed, so the
login prompt doesn't flicker. The live ISO no longer auto-logs `root` in
on tty1 (releng default) — that handed the console to a shell before the
banner could update; tty1 now stays at the login prompt with a live
banner, and the banner tells you a shell is `root` + Enter away. Boxes
that self-update get the new timer linked + enabled automatically; the
refresh loop takes effect after their next reboot.
## [26.19-alpha] - 2026-08-23
### Fixed
- **App installs now copy subdirectories, so dependency hooks actually
ship.** `install_from()` only copied an app's top-level files, so the
`scripts/` folder — where a provider's `on_install`/`on_start` dependency
hooks live — never reached `/var/lib/furtka/apps/<app>/`, and every hooked
dependency failed at reconcile with `hook ... missing in provider`. The
whole app folder is copied now (`rmtree` + `copytree`, so a reinstall also
drops files that were removed upstream). Found during the first real
end-to-end run of the mosquitto + zigbee2mqtt pair on a test VM.
- **`furtka app install <name>` now runs `on_install` hooks.** Named CLI
installs copied the app folders and then ran a bare reconcile, which fires
`on_start` but never `on_install` — so a CLI install of a consumer brought
its provider up without ever provisioning it (no account created, empty
`MQTT_*` values in the consumer's `.env`). Named installs now go through
`install_runner.run_install` (writing the plan file first), the same docker
phase the API dispatches, so providers come up before consumers and
`on_install` hooks run. Path-based installs keep the copy + reconcile
dev/test path.
## [26.18-alpha] - 2026-06-04 ## [26.18-alpha] - 2026-06-04
### Fixed ### Fixed
@ -493,7 +635,11 @@ First tagged snapshot. Pre-alpha — the installer does not yet boot, but the de
- **Containers:** Docker + Compose - **Containers:** Docker + Compose
- **License:** AGPL-3.0 - **License:** AGPL-3.0
[Unreleased]: https://forgejo.sourcegate.online/daniel/furtka/compare/26.16-alpha...HEAD [Unreleased]: https://forgejo.sourcegate.online/daniel/furtka/compare/26.20-alpha...HEAD
[26.20-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.20-alpha
[26.19-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.19-alpha
[26.18-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.18-alpha
[26.17-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.17-alpha
[26.16-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.16-alpha [26.16-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.16-alpha
[26.15-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.15-alpha [26.15-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.15-alpha
[26.14-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.14-alpha [26.14-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.14-alpha

View file

@ -1,3 +1,5 @@
<p><img src="docs/brand/furtka-lockup.svg" alt="Furtka" height="56"></p>
# Furtka # Furtka
**Open-source home server OS — simple enough for everyone.** · [furtka.org](https://furtka.org) **Open-source home server OS — simple enough for everyone.** · [furtka.org](https://furtka.org)
@ -102,6 +104,7 @@ None of these nail the "your dad can set this up" experience. The installer wiza
- [x] Installer webapp prototype — device reader + form → JSON (Robert) - [x] Installer webapp prototype — device reader + form → JSON (Robert)
- [x] Arch running on Proxmox, custom image builds in progress (Robert) - [x] Arch running on Proxmox, custom image builds in progress (Robert)
- [x] Competitor analysis — see [docs/competitors.md](docs/competitors.md) - [x] Competitor analysis — see [docs/competitors.md](docs/competitors.md)
- [x] First bare-metal install (old laptop board, BIOS, SSD with a previous Proxmox VE) — 4 bugs found and shipped in 26.20-alpha, report in [docs/hardware-test-2026-08.md](docs/hardware-test-2026-08.md)
- [x] Wizard flow spec — see [docs/wizard-flow.md](docs/wizard-flow.md) - [x] Wizard flow spec — see [docs/wizard-flow.md](docs/wizard-flow.md)
- [x] Release process + CI — CalVer tags, conventional commits, Forgejo Actions (ruff, pytest, JSON, link checks), `26.0-alpha` tagged - [x] Release process + CI — CalVer tags, conventional commits, Forgejo Actions (ruff, pytest, JSON, link checks), `26.0-alpha` tagged
- [x] Forgejo runner live on Proxmox VM (`forge-runner-01`, Ubuntu 24.04) — docker-outside-of-docker with host-mode jobs for ISO builds, setup captured in [docs/runner-setup.md](docs/runner-setup.md) + [ops/forgejo-runner/](ops/forgejo-runner/) - [x] Forgejo runner live on Proxmox VM (`forge-runner-01`, Ubuntu 24.04) — docker-outside-of-docker with host-mode jobs for ISO builds, setup captured in [docs/runner-setup.md](docs/runner-setup.md) + [ops/forgejo-runner/](ops/forgejo-runner/)

View file

@ -45,7 +45,7 @@ Tag per meaningful milestone, not on a calendar. A milestone is: ISO boots, a wi
git push origin 26.1-alpha git push origin 26.1-alpha
``` ```
5. **The release workflow does the rest.** `.forgejo/workflows/release.yml` fires on the tag push and runs on the self-hosted runner: `scripts/build-release-tarball.sh` builds the self-update payload (tarball + sha256 + release.json under `dist/`), `iso/build.sh` builds the live-installer ISO, `scripts/publish-release.sh` uploads tarball + sha256 + release.json + ISO to the Forgejo release page. Pre-release is flagged automatically based on the suffix (`-alpha`/`-beta`/`-rc`). ISO build is `continue-on-error`: a flaky ISO step doesn't block the core tarball (the thing boxes need for self-update). 5. **The release workflow does the rest.** `.forgejo/workflows/release.yml` fires on the tag push and runs on the self-hosted runner: `scripts/build-release-tarball.sh` builds the self-update payload (tarball + sha256 + release.json under `dist/`), `iso/build.sh` builds the live-installer ISO, `scripts/publish-release.sh` uploads tarball + sha256 + release.json + ISO to the Forgejo release page. Pre-release is flagged automatically based on the suffix (`-alpha`/`-beta`/`-rc`). The ISO gets a `furtka-<ver>.iso.sha256` sidecar, and a last step rebuilds furtka.org so the download page (`/download/`) picks up the new ISO, size, date and checksum from the Forgejo API. ISO build is `continue-on-error`: a flaky ISO step doesn't block the core tarball (the thing boxes need for self-update).
The release workflow needs one secret set at repo **Settings → Secrets → Actions**: The release workflow needs one secret set at repo **Settings → Secrets → Actions**:
- `FORGEJO_RELEASE_TOKEN` — a PAT with `write:repository` scope. - `FORGEJO_RELEASE_TOKEN` — a PAT with `write:repository` scope.

View file

@ -1,22 +1,47 @@
#!/bin/bash #!/bin/bash
# Regenerates /etc/issue on the installed system so the console tells the # Regenerates /etc/issue on the installed system so the console tells the
# user which URL to open. Mirrors the live-ISO furtka-update-issue pattern. # user which URL to open. Mirrors the live-ISO furtka-update-issue pattern:
set -e # runs at boot and every few seconds from furtka-welcome.timer, so the IP
# fallback line is always on screen — with the address once DHCP handed one
# out, or with a "no IP yet" hint before that / after the cable is pulled.
# Only rewrites + nudges agetty when the text changed (no prompt flicker).
set -u
hostname=$(cat /etc/hostname) issue=/etc/issue
ip=$(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)
{ hostname=$(cat /etc/hostname 2>/dev/null || hostname)
mapfile -t ips < <(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1)
new=$(
# Furtka mark (docs/brand/console-banner.txt, from brand-assets.py)
echo ' ▄▄▄▄▄▄▄▄'
echo ' ▄██████████▄'
echo ' █████▀▀▀▀▀▀███'
echo ' ████▀ ████'
echo ' ████ █████████'
echo ' ████ ████'
echo ' ████ ▄▄▄▄▄████'
echo ' ████ █████████'
echo ' ████ █████████'
echo ' ▀▀▀▀ ▀▀▀▀▀▀▀▀▀'
echo echo
echo " Furtka is ready." echo " Furtka is ready."
echo echo
echo " Open in a browser on another device on your network:" echo " Open in a browser on another device on your network:"
echo echo
echo " http://${hostname}.local (easy — try this first)" echo " http://${hostname}.local (easy — try this first)"
if [ -n "$ip" ]; then if ((${#ips[@]})); then
for ip in "${ips[@]}"; do
echo " http://${ip} (fallback if the first doesn't work)" echo " http://${ip} (fallback if the first doesn't work)"
done
else
echo " (fallback: no IP address yet — waiting for the network."
echo " Check the cable / DHCP. This line updates by itself.)"
fi fi
echo echo
} > /etc/issue )
agetty --reload 2>/dev/null || true if [ "$new" != "$(cat "$issue" 2>/dev/null)" ]; then
printf '%s\n' "$new" > "$issue"
agetty --reload 2>/dev/null || true
fi

View file

@ -2,11 +2,20 @@
Description=Furtka apps catalog sync Description=Furtka apps catalog sync
Requires=network-online.target Requires=network-online.target
After=network-online.target After=network-online.target
# network-online.target is satisfied by *any* routable address. On dual-stack
# LANs that is usually the IPv6 SLAAC address, which lands a second or so
# before DHCPv4 — and the first sync then dies with "Network is unreachable"
# (first hardware bench, 2026-08-25). Retry a few times instead of waiting
# for the daily timer.
StartLimitIntervalSec=15min
StartLimitBurst=8
[Service] [Service]
Type=oneshot Type=oneshot
ExecStart=/usr/local/bin/furtka catalog sync ExecStart=/usr/local/bin/furtka catalog sync
TimeoutStartSec=5min TimeoutStartSec=5min
Restart=on-failure
RestartSec=20s
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View file

@ -1,12 +1,12 @@
[Unit] [Unit]
Description=Furtka console welcome banner Description=Furtka console welcome banner
After=network-online.target # No network-online dependency on purpose: paint the banner (with a
Wants=network-online.target # "no IP yet" fallback) as early as possible; furtka-welcome.timer re-runs
# it every few seconds so the address appears as soon as DHCP delivers.
[Service] [Service]
Type=oneshot Type=oneshot
ExecStart=/opt/furtka/current/assets/bin/furtka-welcome ExecStart=/opt/furtka/current/assets/bin/furtka-welcome
RemainAfterExit=yes
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View file

@ -0,0 +1,10 @@
[Unit]
Description=Keep the Furtka console welcome (IP fallback) up to date
[Timer]
OnBootSec=3s
OnUnitActiveSec=5s
AccuracySec=1s
[Install]
WantedBy=timers.target

1
assets/www/favicon.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><style>svg{color:#c03a28}@media (prefers-color-scheme:dark){svg{color:#ff6b56}}</style><path fill="currentColor" fill-rule="evenodd" d="M5 60V27a27 27 0 0 1 54 0v33Z M19.5 60V26A10 10 0 0 1 29.5 16H46v10H32a2.5 2.5 0 0 0-2.5 2.5V32H46v10H29.5v18Z"/></svg>

Before

Width:  |  Height:  |  Size: 316 B

After

Width:  |  Height:  |  Size: 316 B

View file

@ -2,6 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<title>Install local HTTPS · Furtka</title> <title>Install local HTTPS · Furtka</title>
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="/style.css"> <link rel="stylesheet" href="/style.css">

View file

@ -2,6 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<title>Furtka</title> <title>Furtka</title>
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="/style.css"> <link rel="stylesheet" href="/style.css">
@ -68,6 +69,15 @@
</main> </main>
<script> <script>
// Sessions live in API memory, so every restart or self-update logs
// everyone out. Send the browser to the login page instead of rendering
// the 401 body as if the box had no apps.
const _fetch = window.fetch.bind(window);
window.fetch = async (...args) => {
const r = await _fetch(...args);
if (r.status === 401) { location.href = '/login'; }
return r;
};
// Revoke the cookie server-side and bounce to /login. Shared // Revoke the cookie server-side and bounce to /login. Shared
// shape with the _HTML in furtka/api.py so the two logout // shape with the _HTML in furtka/api.py so the two logout
// links behave identically. // links behave identically.

View file

@ -2,6 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<title>Settings · Furtka</title> <title>Settings · Furtka</title>
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="/style.css"> <link rel="stylesheet" href="/style.css">
@ -160,6 +161,15 @@
</main> </main>
<script> <script>
// Sessions live in API memory, so every restart or self-update logs
// everyone out. Send the browser to the login page instead of rendering
// the 401 body as if the box had no apps.
const _fetch = window.fetch.bind(window);
window.fetch = async (...args) => {
const r = await _fetch(...args);
if (r.status === 401) { location.href = '/login'; }
return r;
};
// Logout button in the nav — same shape as /apps and / pages. // Logout button in the nav — same shape as /apps and / pages.
async function doLogout(ev) { async function doLogout(ev) {
ev.preventDefault(); ev.preventDefault();
@ -200,6 +210,7 @@
done: 'Update complete — reloading…', done: 'Update complete — reloading…',
rolled_back: 'Update failed, rolled back to the previous version', rolled_back: 'Update failed, rolled back to the previous version',
rolled_back_manual: 'Rolled back manually', rolled_back_manual: 'Rolled back manually',
error: 'Update failed — nothing was changed',
}; };
let pollHandle = null; let pollHandle = null;
@ -504,12 +515,13 @@
if (!r.ok) return; if (!r.ok) return;
const s = await r.json(); const s = await r.json();
const label = STAGE_LABELS[s.stage] || `Stage: ${s.stage}`; const label = STAGE_LABELS[s.stage] || `Stage: ${s.stage}`;
setStatus(label, s.stage === 'rolled_back'); const failed = s.stage === 'rolled_back' || s.stage === 'error';
setStatus(label, failed);
if (s.stage === 'done') { if (s.stage === 'done') {
clearInterval(pollHandle); clearInterval(pollHandle);
clearTimeout(fallbackReloadHandle); clearTimeout(fallbackReloadHandle);
setTimeout(() => location.reload(), 5000); setTimeout(() => location.reload(), 5000);
} else if (s.stage === 'rolled_back') { } else if (failed) {
clearInterval(pollHandle); clearInterval(pollHandle);
clearTimeout(fallbackReloadHandle); clearTimeout(fallbackReloadHandle);
if (s.reason) { if (s.reason) {

View file

@ -73,13 +73,17 @@ body {
align-items: center; align-items: center;
gap: 0.55rem; gap: 0.55rem;
} }
/* Furtka mark (M), drawn as a mask so it takes the accent colour in both
themes. Generated geometry: website/tools/brand-assets.py. */
.brand::before { .brand::before {
content: ""; content: "";
width: 0.7rem; width: 1.35rem;
height: 0.7rem; height: 1.35rem;
background: var(--accent); /* Brand red, not the UI's green accent — the logo keeps its colour.
border-radius: 2px; This stylesheet is dark-first; the light theme gets the deeper red. */
transform: rotate(45deg); background: #ff6b56;
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cpath fill-rule='evenodd' d='M5 60V27a27 27 0 0 1 54 0v33Z M19.5 60V26A10 10 0 0 1 29.5 16H46v10H32a2.5 2.5 0 0 0-2.5 2.5V32H46v10H29.5v18Z'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cpath fill-rule='evenodd' d='M5 60V27a27 27 0 0 1 54 0v33Z M19.5 60V26A10 10 0 0 1 29.5 16H46v10H32a2.5 2.5 0 0 0-2.5 2.5V32H46v10H29.5v18Z'/%3E%3C/svg%3E") center / contain no-repeat;
} }
.nav-links { display: flex; gap: 0.25rem; } .nav-links { display: flex; gap: 0.25rem; }
.nav-links a { .nav-links a {
@ -543,3 +547,8 @@ details.log-details[open] > summary { color: var(--fg); }
color: var(--accent); color: var(--accent);
} }
.app-icon svg { width: 36px; height: 36px; } .app-icon svg { width: 36px; height: 36px; }
.login-brand { margin: 0 0 1.5rem; font-size: 1.2rem; }
@media (prefers-color-scheme: light) {
.brand::before { background: #c03a28; }
}

19
docs/brand/README.md Normal file
View file

@ -0,0 +1,19 @@
# Furtka brand files
The Furtka logo is **Mark M** — a lowercase *f* cut out of an arched gate. It won the
public logo vote on furtka.org (closed 2026-09-28: M 29, R 13 of 42 votes).
| File | Use |
|---|---|
| `furtka-mark.svg` | The mark, 32 px and up |
| `furtka-mark-small.svg` | Simplified drawing for 16–24 px (favicons, tab icons) |
| `furtka-lockup.svg` | Mark + wordmark, accent colour on light grounds |
| `furtka-lockup-white.svg` | Mark + wordmark in white, for dark or accent grounds |
| `console-banner.txt` | Text-mode art for the console welcome screens |
Colours: accent `#c03a28` (dark UI: `#ff6b56`), ink `#0d0d0f`.
Everything here and the copies in `website/static/`, `webinstaller/static/`,
`assets/www/` and `iso/overlay/splash.png` are generated by
`website/tools/brand-assets.py` from the geometry in `website/tools/logo-assets.py`.
Change the geometry there and re-run the script — don't edit the outputs by hand.

View file

@ -0,0 +1,10 @@
▄▄▄▄▄▄▄▄
▄██████████▄
█████▀▀▀▀▀▀███
████▀ ████
████ █████████
████ ████
████ ▄▄▄▄▄████
████ █████████
████ █████████
▀▀▀▀ ▀▀▀▀▀▀▀▀▀

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -4 232.44 64.00" role="img" aria-label="Furtka"><g transform="scale(1.0000) translate(-6 -2)"><path fill="#ffffff" fill-rule="evenodd" d="M6 60V28a26 26 0 0 1 52 0v32Z M20.0 60V26.5A10.5 10.5 0 0 1 30.5 16H45.0v8.6H32.5a3.5 3.5 0 0 0-3.5 3.5V32h16v8.6H29.0V60Z"/></g><g fill="#ffffff"><path transform="translate(68.24 58.00) scale(0.05354 -0.05354)" d="M115 0V494H30V600H115V637Q115 687 132.5 724.0Q150 761 192.0 781.5Q234 802 305 802Q333 802 358 799V695Q344 698 324 698Q287 698 273.5 682.0Q260 666 260 632V600H358V494H260V0Z"/><path transform="translate(87.21 58.00) scale(0.05354 -0.05354)" d="M271 -15Q169 -15 119.0 48.5Q69 112 69 218V600H218V244Q218 179 241.5 143.0Q265 107 316 107Q379 107 413.0 156.0Q447 205 447 276V600H596V0H462V90H438Q423 44 382.5 14.5Q342 -15 271 -15Z"/><path transform="translate(121.45 58.00) scale(0.05354 -0.05354)" d="M76 0V600H211V492H235Q241 521 257.0 547.0Q273 573 307.0 589.5Q341 606 399 606H428V471H387Q301 471 263.0 427.0Q225 383 225 294V0Z"/><path transform="translate(144.17 58.00) scale(0.05354 -0.05354)" d="M162 0Q139 27 125.5 65.0Q112 103 112 179V494H30V600H114V750H259V600H358V494H260V196Q260 116 278.0 74.5Q296 33 321 6V0Z"/><path transform="translate(163.14 58.00) scale(0.05354 -0.05354)" d="M78 0V800H226V366H274L449 600H608V594L397 320L632 6V0H457L274 254H226V0Z"/><path transform="translate(195.88 58.00) scale(0.05354 -0.05354)" d="M280 -13Q198 -13 144.0 27.5Q90 68 63.5 138.5Q37 209 37 301Q37 395 64.0 465.0Q91 535 145.0 574.0Q199 613 280 613Q344 613 384.5 587.5Q425 562 445 522H469V600H606V0H469V78H445Q425 41 385.0 14.0Q345 -13 280 -13ZM317 95Q381 95 419.5 151.0Q458 207 458 300Q458 394 419.5 449.5Q381 505 317 505Q257 505 223.5 453.0Q190 401 190 300Q190 199 223.5 147.0Q257 95 317 95Z"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -4 232.44 64.00" role="img" aria-label="Furtka"><g transform="scale(1.0000) translate(-6 -2)"><path fill="#c03a28" fill-rule="evenodd" d="M6 60V28a26 26 0 0 1 52 0v32Z M20.0 60V26.5A10.5 10.5 0 0 1 30.5 16H45.0v8.6H32.5a3.5 3.5 0 0 0-3.5 3.5V32h16v8.6H29.0V60Z"/></g><g fill="#c03a28"><path transform="translate(68.24 58.00) scale(0.05354 -0.05354)" d="M115 0V494H30V600H115V637Q115 687 132.5 724.0Q150 761 192.0 781.5Q234 802 305 802Q333 802 358 799V695Q344 698 324 698Q287 698 273.5 682.0Q260 666 260 632V600H358V494H260V0Z"/><path transform="translate(87.21 58.00) scale(0.05354 -0.05354)" d="M271 -15Q169 -15 119.0 48.5Q69 112 69 218V600H218V244Q218 179 241.5 143.0Q265 107 316 107Q379 107 413.0 156.0Q447 205 447 276V600H596V0H462V90H438Q423 44 382.5 14.5Q342 -15 271 -15Z"/><path transform="translate(121.45 58.00) scale(0.05354 -0.05354)" d="M76 0V600H211V492H235Q241 521 257.0 547.0Q273 573 307.0 589.5Q341 606 399 606H428V471H387Q301 471 263.0 427.0Q225 383 225 294V0Z"/><path transform="translate(144.17 58.00) scale(0.05354 -0.05354)" d="M162 0Q139 27 125.5 65.0Q112 103 112 179V494H30V600H114V750H259V600H358V494H260V196Q260 116 278.0 74.5Q296 33 321 6V0Z"/><path transform="translate(163.14 58.00) scale(0.05354 -0.05354)" d="M78 0V800H226V366H274L449 600H608V594L397 320L632 6V0H457L274 254H226V0Z"/><path transform="translate(195.88 58.00) scale(0.05354 -0.05354)" d="M280 -13Q198 -13 144.0 27.5Q90 68 63.5 138.5Q37 209 37 301Q37 395 64.0 465.0Q91 535 145.0 574.0Q199 613 280 613Q344 613 384.5 587.5Q425 562 445 522H469V600H606V0H469V78H445Q425 41 385.0 14.0Q345 -13 280 -13ZM317 95Q381 95 419.5 151.0Q458 207 458 300Q458 394 419.5 449.5Q381 505 317 505Q257 505 223.5 453.0Q190 401 190 300Q190 199 223.5 147.0Q257 95 317 95Z"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="#c03a28" fill-rule="evenodd" d="M5 60V27a27 27 0 0 1 54 0v33Z M19.5 60V26A10 10 0 0 1 29.5 16H46v10H32a2.5 2.5 0 0 0-2.5 2.5V32H46v10H29.5v18Z"/></svg>

Before

Width:  |  Height:  |  Size: 224 B

After

Width:  |  Height:  |  Size: 224 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="#c03a28" fill-rule="evenodd" d="M6 60V28a26 26 0 0 1 52 0v32Z M20.0 60V26.5A10.5 10.5 0 0 1 30.5 16H45.0v8.6H32.5a3.5 3.5 0 0 0-3.5 3.5V32h16v8.6H29.0V60Z"/></svg>

Before

Width:  |  Height:  |  Size: 236 B

After

Width:  |  Height:  |  Size: 236 B

View file

@ -0,0 +1,75 @@
# Hardware test report — "junk bench", 2026-08-24/26
First end-to-end run of Furtka on real hardware that was not a Proxmox VM
and not the Medion box: a bare old-laptop mainboard lying on the desk, a
1 TB Crucial BX SATA SSD that had previously lived in a Proxmox VE host, a
USB hub for keyboard + boot stick, LAN cable, HDMI monitor. Tester: Daniel.
Release under test: **26.19-alpha**, then self-updated to **26.20-alpha**.
Why it mattered: every bug below is invisible on the smoke VM. A VM has a
fresh empty disk, a fast virtual NIC, UEFI firmware and no USB stick to pull.
## Timeline
| When | What happened | Outcome |
|---|---|---|
| 08-24 | ISO flashed with Fedora Media Writer, bench booted, browser opened `proski.local:5000` | "Server Not Found" — **typo** (`proski` vs `proksi`). Bench powered off. |
| 08-25 | `proksi.local:5000` — wizard came up (BIOS/syslinux boot path, stock Arch splash) | OK |
| 08-25 13:07 | Install run 1 | **Failed at 12 %**: `Partition(s) 2, 3 on /dev/sda have been written, but we have been unable to inform the kernel of the change … in use` |
| 08-25 | Root shell over SSH (`passwd` + `ssh-copy-id` on the live ISO): SSD still carried a Proxmox VE `pve` volume group (swap, root, thin pool); udev had activated it, six device-mapper nodes sat on `sda3` | Cleared by hand: `dmsetup remove` ×6, `wipefs -a /dev/sda`, `blockdev --rereadpt` |
| 08-25 13:16 | Install run 2 | `Installation completed without any errors`; BIOS detected → `Grub`, MBR table, `sda1` 1 G vfat `/boot`, `sda2` ext4 `/` |
| 08-25 | "Reboot now" clicked | Box **booted the USB stick again** (BIOS order). Stick pulled, rebooted → `furtka.local` = 192.168.178.142, Caddy answering |
| 08-25 16:22 | First boot of the installed system | `furtka-catalog-sync.service` failed: `Network is unreachable`. `network-online.target` had fired on the IPv6 SLAAC address ~1 s before DHCPv4 landed. State file stuck on `checking`, apps page empty; next timer run would have been 18:26 |
| 08-25 ~17:00 | Catalog synced via the "Sync apps catalog" button | Catalog 26.13-alpha, 7 apps listed |
| 08-25 17:47 | 26.20-alpha released with the fixes below | Box reports `Update available: 26.19-alpha → 26.20-alpha` |
| 08-26 09:34 | Self-update to 26.20-alpha from `/settings` | `current → 26.20-alpha`, no failed units. **New finding:** `furtka-welcome.timer` linked + enabled by the updater but `inactive (dead)` — `enable` alone only arms it for the next boot |
## Findings and fixes
| # | Finding | Root cause | Fix | Shipped in | Validated on hardware |
|---|---|---|---|---|---|
| 1 | Console banner showed only `proksi.local` / `furtka.local`, never the IP fallback | Banner script was a oneshot after `network-online.target` and dropped the IP line when no address existed yet. On the live ISO releng's root autologin on tty1 also made `agetty --reload` a no-op | `furtka-issue.timer` (ISO) / `furtka-welcome.timer` (installed) re-run the script every 5 s; fallback line is always present ("no IP address yet — check the cable / DHCP" until one arrives); ISO no longer auto-logs root in on tty1 | 26.20-alpha (`0e05bef`) | Installed side: pending reboot or next update. ISO side: pending reinstall from the 26.20 ISO |
| 2 | Install failed at partitioning on a disk with a previous OS | Live ISO ships lvm2/mdadm/dmraid/cryptsetup; udev auto-activates whatever the disk carries; parted cannot re-read the partition table while dm nodes hold the partitions | `webinstaller/diskprep.py`: `release_disk()` runs before archinstall — swapoff/umount leaves-first, `dmsetup remove` / `mdadm --stop`, `wipefs -a`, `blockdev --rereadpt`; steps logged at the top of the install log; progress page names this failure explicitly | 26.20-alpha (`5ddda53`) | Pending reinstall from the 26.20 ISO (the disk now carries Furtka itself, which exercises the same path minus LVM) |
| 3 | "Remove the USB stick" was easy to miss — the box booted the installer again | Hint lived on the *rebooting* page and in a `confirm()` popup, i.e. after the click, with the machine restarting 3 s later | Done state shows a two-step list (pull the stick → restart) above the button; button reads "USB stick is out — restart now" | 26.20-alpha (`60404ae`) | Pending reinstall |
| 4 | No apps after first boot | Boot-time catalog sync raced DHCPv4 (IPv6 made `network-online` fire early), no retry, timer jitter pushed the next attempt 2 h out, state file stuck on `checking` | `furtka-catalog-sync.service` retries on failure (20 s, up to 8× in 15 min); failures recorded as stage `error`; apps page shows "last sync failed: …" | 26.20-alpha (`32ba522`) | Retry config confirmed live on the box after the update; a cold boot with the new unit is still pending |
| 5 | New timer dead after self-update | `_link_new_units()` linked + enabled but never started the timer; also linked from the versioned dir instead of `current` | Link via `current`, enable, start | Unreleased (this branch) | — |
Not a bug, but worth knowing: on this board the BIOS boots the USB stick
before the SSD, so every reinstall needs the stick pulled at the reboot
prompt. The stock Arch splash on the BIOS/syslinux boot path is unbranded
(only menu labels are) — cosmetic.
## What worked first time
- Fedora Media Writer → hybrid ISO boots on BIOS firmware.
- mDNS: `proksi.local` (live ISO) and `furtka.local` (installed) resolved
from a Fedora client once spelled correctly.
- BIOS detection → `Grub` + MBR layout; the installed system booted from
the SSD without intervention.
- Login, `/settings`, catalog sync via the UI button, `furtka update --check`,
self-update 26.19 → 26.20 with symlink flip and clean unit state.
- Time sync, Docker 29.7.2, Caddy on :80, sshd with key auth for the wizard
user (`wheel` + `docker` groups as configured).
## Still open from this run
- Reinstall from the 26.20-alpha ISO on the bench to validate findings 1–3
on the ISO side (the installed side of #1 validates on the next reboot).
- Cold boot with the retrying catalog-sync unit (#4) on a dual-stack LAN.
- Rebrand the syslinux boot splash.
- `smoke-latest.yml` is `workflow_dispatch` only and has not run since
April; the smoke VM would not have caught any of the above anyway, but it
is currently not running at all.
## Reproducing the bench conditions in a VM
Most of this can be provoked on Proxmox if you want a regression check
without the hardware:
- Finding 2: attach a disk that already carries an LVM install (e.g. a
spare Proxmox VE or Ubuntu LVM disk image) before booting the ISO.
- Finding 3: leave the ISO attached as CD-ROM with boot order CD-first.
- Finding 4: give the VM's bridge IPv6 RA before DHCPv4 — harder; the
retry logic is unit-tested instead.
- Finding 1: use SeaBIOS instead of OVMF (the README notes SeaBIOS fails on
`ldlinux.c32` from this ISO, so this one really needs hardware).

View file

@ -15,6 +15,7 @@ neutral ``ReleaseError`` for use in tests or standalone scripts.
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import http.client
import json import json
import shutil import shutil
import tarfile import tarfile
@ -33,7 +34,9 @@ def forgejo_api(host: str, repo: str, path: str, *, error_cls: type = ReleaseErr
try: try:
with urllib.request.urlopen(req, timeout=15) as resp: with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read()) return json.loads(resp.read())
except (urllib.error.URLError, json.JSONDecodeError) as e: except (OSError, http.client.HTTPException, json.JSONDecodeError) as e:
# OSError covers URLError plus socket timeouts and resets while
# reading the body, which urllib doesn't wrap.
raise error_cls(f"forgejo api {url}: {e}") from e raise error_cls(f"forgejo api {url}: {e}") from e
@ -43,7 +46,8 @@ def download(url: str, dest: Path, *, error_cls: type = ReleaseError) -> None:
try: try:
with urllib.request.urlopen(req, timeout=60) as resp, dest.open("wb") as f: with urllib.request.urlopen(req, timeout=60) as resp, dest.open("wb") as f:
shutil.copyfileobj(resp, f) shutil.copyfileobj(resp, f)
except urllib.error.URLError as e: except (OSError, http.client.HTTPException) as e:
# Includes disk full while writing `dest` and mid-body disconnects.
raise error_cls(f"download {url}: {e}") from e raise error_cls(f"download {url}: {e}") from e

View file

@ -17,12 +17,14 @@ stopgap.
import json import json
import re import re
import threading
import time import time
import traceback
from http.cookies import SimpleCookie from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from furtka import auth, deps, dockerops, install_runner, installer, reconciler, sources from furtka import auth, deps, dockerops, install_runner, installer, reconciler, sources
from furtka.manifest import ManifestError, load_manifest from furtka.manifest import APP_NAME_RE, ManifestError, load_manifest
from furtka.paths import apps_dir, static_www_dir from furtka.paths import apps_dir, static_www_dir
from furtka.scanner import scan from furtka.scanner import scan
@ -71,6 +73,7 @@ _HTML = """<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<title>Furtka Apps</title> <title>Furtka Apps</title>
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="/style.css"> <link rel="stylesheet" href="/style.css">
@ -120,6 +123,15 @@ _HTML = """<!DOCTYPE html>
</div> </div>
<script> <script>
// Sessions live in API memory, so every restart or self-update logs
// everyone out. Send the browser to the login page instead of rendering
// the 401 body as if the box had no apps.
const _fetch = window.fetch.bind(window);
window.fetch = async (...args) => {
const r = await _fetch(...args);
if (r.status === 401) { location.href = '/login'; }
return r;
};
function esc(s) { function esc(s) {
const d = document.createElement('div'); const d = document.createElement('div');
d.textContent = s == null ? '' : String(s); d.textContent = s == null ? '' : String(s);
@ -584,7 +596,11 @@ async function refreshCatalog() {
const updatedAt = (status.state || {}).updated_at || ''; const updatedAt = (status.state || {}).updated_at || '';
document.getElementById('catalog-last-sync').textContent = updatedAt || 'never'; document.getElementById('catalog-last-sync').textContent = updatedAt || 'never';
const stageEl = document.getElementById('catalog-stage'); const stageEl = document.getElementById('catalog-stage');
if (stage && stage !== 'done') { if (stage === 'error') {
const err = (status.state || {}).error || 'unknown error';
stageEl.textContent = '· last sync failed: ' + err;
stageEl.classList.add('pending');
} else if (stage && stage !== 'done') {
stageEl.textContent = '· ' + stage + '…'; stageEl.textContent = '· ' + stage + '…';
stageEl.classList.add('pending'); stageEl.classList.add('pending');
} else { } else {
@ -636,12 +652,14 @@ _HTML_LOGIN = """<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<title>Furtka · {{ TITLE }}</title> <title>Furtka · {{ TITLE }}</title>
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="/style.css"> <link rel="stylesheet" href="/style.css">
</head> </head>
<body> <body>
<main class="wrap login-wrap"> <main class="wrap login-wrap">
<p class="brand login-brand">Furtka</p>
<h1>{{ HEADING }}</h1> <h1>{{ HEADING }}</h1>
<p class="lede">{{ LEDE }}</p> <p class="lede">{{ LEDE }}</p>
<form id="login-form" onsubmit="return doLogin(event)"> <form id="login-form" onsubmit="return doLogin(event)">
@ -856,7 +874,7 @@ def _do_get_settings(name):
} }
_INSTALL_TERMINAL_STAGES = frozenset({"done", "error"}) _INSTALL_TERMINAL_STAGES = install_runner.TERMINAL_STAGES
def _do_install_plan(name): def _do_install_plan(name):
@ -910,8 +928,9 @@ def _do_install(name, settings=None, confirm_dependencies=False):
# POST can sneak in during that tiny window. Reading the state # POST can sneak in during that tiny window. Reading the state
# first closes that gap: as long as a previous install hasn't # first closes that gap: as long as a previous install hasn't
# written "done" or "error", we refuse. # written "done" or "error", we refuse.
install_runner.clear_stale_state()
current_state = install_runner.read_state() current_state = install_runner.read_state()
current_stage = current_state.get("stage", "") if isinstance(current_state, dict) else "" current_stage = current_state.get("stage", "")
if current_stage and current_stage not in _INSTALL_TERMINAL_STAGES: if current_stage and current_stage not in _INSTALL_TERMINAL_STAGES:
return 409, { return 409, {
"error": ( "error": (
@ -1007,6 +1026,7 @@ def _do_install(name, settings=None, confirm_dependencies=False):
def _do_install_status(): def _do_install_status():
"""Return the current install-state.json contents (or {}).""" """Return the current install-state.json contents (or {})."""
install_runner.clear_stale_state()
return 200, install_runner.read_state() return 200, install_runner.read_state()
@ -1531,7 +1551,33 @@ def _parse_settings_body(payload):
return out return out
# Largest request body we read. Every legitimate body is a small JSON
# object; without a cap a LAN client could make us buffer gigabytes.
MAX_BODY_BYTES = 1024 * 1024
# The server is threaded so status polls and page loads keep answering while
# a slow request (image pull, reconcile) runs. Requests that change state
# still run one at a time, as they did on the single-threaded server.
_MUTATION_LOCK = threading.Lock()
# First-run admin creation is check-then-write; two setup POSTs racing on
# the threaded server must not both create an admin.
_SETUP_LOCK = threading.Lock()
_USERS_FILE_BROKEN = (
"The login file on this box (/var/lib/furtka/users.json) is damaged. "
"Log in over SSH, delete that file, then reload this page to set a new admin password."
)
def _valid_app_name(name) -> bool:
return isinstance(name, str) and APP_NAME_RE.fullmatch(name) is not None
class _Handler(BaseHTTPRequestHandler): class _Handler(BaseHTTPRequestHandler):
# Socket read timeout: a client that stops sending mid-request gets
# dropped instead of holding a worker forever.
timeout = 30
def _json(self, status, payload, extra_headers=None): def _json(self, status, payload, extra_headers=None):
body = json.dumps(payload).encode() body = json.dumps(payload).encode()
self.send_response(status) self.send_response(status)
@ -1640,6 +1686,14 @@ class _Handler(BaseHTTPRequestHandler):
return self._json(400, {"error": "password is required"}) return self._json(400, {"error": "password is required"})
username = username.strip() username = username.strip()
try:
with _SETUP_LOCK:
return self._login_or_setup(payload, username, password)
except auth.UsersFileError:
traceback.print_exc()
return self._json(503, {"error": _USERS_FILE_BROKEN})
def _login_or_setup(self, payload, username, password):
if auth.setup_needed(): if auth.setup_needed():
# First-run setup path — create the admin account, then log # First-run setup path — create the admin account, then log
# in. Require password2 so a typo doesn't lock the user out # in. Require password2 so a typo doesn't lock the user out
@ -1686,13 +1740,39 @@ class _Handler(BaseHTTPRequestHandler):
auth.SESSIONS.revoke(morsel.value) auth.SESSIONS.revoke(morsel.value)
return self._json(200, {"ok": True}, extra_headers=[self._clear_cookie_header()]) return self._json(200, {"ok": True}, extra_headers=[self._clear_cookie_header()])
def _internal_error(self):
# Without this, an unexpected exception makes socketserver drop the
# connection and the UI shows "Failed to fetch" with nothing to go on.
traceback.print_exc()
try:
self._json(500, {"error": "internal error — see `journalctl -u furtka-api`"})
except OSError:
pass
def do_GET(self): # noqa: N802 — http.server convention def do_GET(self): # noqa: N802 — http.server convention
try:
self._route_get()
except Exception:
self._internal_error()
def do_POST(self): # noqa: N802
try:
self._route_post()
except Exception:
self._internal_error()
def _route_get(self):
# --- Public routes: login page + its assets ------------------ # --- Public routes: login page + its assets ------------------
if self.path in ("/login", "/login/"): if self.path in ("/login", "/login/"):
# Already authed? Skip straight to the app list. # Already authed? Skip straight to the app list.
if self._current_session() is not None: if self._current_session() is not None:
return self._redirect("/apps") return self._redirect("/apps")
return self._html(200, _render_login_html(auth.setup_needed())) try:
setup = auth.setup_needed()
except auth.UsersFileError:
traceback.print_exc()
return self._html(503, f"<h1>Login unavailable</h1><p>{_USERS_FILE_BROKEN}</p>")
return self._html(200, _render_login_html(setup))
# --- Auth guard for everything below ------------------------- # --- Auth guard for everything below -------------------------
session = self._current_session() session = self._current_session()
@ -1746,7 +1826,7 @@ class _Handler(BaseHTTPRequestHandler):
# /api/apps/<name>/settings # /api/apps/<name>/settings
if self.path.startswith("/api/apps/") and self.path.endswith("/settings"): if self.path.startswith("/api/apps/") and self.path.endswith("/settings"):
name = self.path[len("/api/apps/") : -len("/settings")] name = self.path[len("/api/apps/") : -len("/settings")]
if "/" in name or not name: if not _valid_app_name(name):
return self._json(400, {"error": "invalid app name"}) return self._json(400, {"error": "invalid app name"})
status, body = _do_get_settings(name) status, body = _do_get_settings(name)
return self._json(status, body) return self._json(status, body)
@ -1759,8 +1839,20 @@ class _Handler(BaseHTTPRequestHandler):
return self._json(status, body) return self._json(status, body)
self._json(404, {"error": "not found"}) self._json(404, {"error": "not found"})
def do_POST(self): # noqa: N802 def _route_post(self):
# Browsers tag every request with where it came from. Only accept
# state changes from our own pages — an app on another port of the
# same host counts as "same-site" and would pass SameSite=Strict.
# Old browsers that don't send the header fall back to the cookie.
fetch_site = self.headers.get("Sec-Fetch-Site")
if fetch_site is not None and fetch_site not in ("same-origin", "none"):
return self._json(403, {"error": "cross-site request refused"})
try:
length = int(self.headers.get("Content-Length", "0")) length = int(self.headers.get("Content-Length", "0"))
except ValueError:
return self._json(400, {"error": "invalid Content-Length"})
if length < 0 or length > MAX_BODY_BYTES:
return self._json(413, {"error": "request body too large"})
raw = self.rfile.read(length) if length else b"" raw = self.rfile.read(length) if length else b""
try: try:
payload = json.loads(raw.decode()) if raw else {} payload = json.loads(raw.decode()) if raw else {}
@ -1779,10 +1871,15 @@ class _Handler(BaseHTTPRequestHandler):
if self._current_session() is None: if self._current_session() is None:
return self._json(401, {"error": "not authenticated"}) return self._json(401, {"error": "not authenticated"})
with _MUTATION_LOCK:
return self._route_authed_post(payload)
def _route_authed_post(self, payload):
# Per-app settings update: /api/apps/<name>/settings # Per-app settings update: /api/apps/<name>/settings
if self.path.startswith("/api/apps/") and self.path.endswith("/settings"): if self.path.startswith("/api/apps/") and self.path.endswith("/settings"):
name = self.path[len("/api/apps/") : -len("/settings")] name = self.path[len("/api/apps/") : -len("/settings")]
if "/" in name or not name: if not _valid_app_name(name):
return self._json(400, {"error": "invalid app name"}) return self._json(400, {"error": "invalid app name"})
settings = _parse_settings_body(payload) settings = _parse_settings_body(payload)
if settings is False or settings is None: if settings is False or settings is None:
@ -1793,7 +1890,7 @@ class _Handler(BaseHTTPRequestHandler):
# Per-app image update: /api/apps/<name>/update # Per-app image update: /api/apps/<name>/update
if self.path.startswith("/api/apps/") and self.path.endswith("/update"): if self.path.startswith("/api/apps/") and self.path.endswith("/update"):
name = self.path[len("/api/apps/") : -len("/update")] name = self.path[len("/api/apps/") : -len("/update")]
if "/" in name or not name: if not _valid_app_name(name):
return self._json(400, {"error": "invalid app name"}) return self._json(400, {"error": "invalid app name"})
status, body = _do_update(name) status, body = _do_update(name)
return self._json(status, body) return self._json(status, body)
@ -1841,6 +1938,8 @@ class _Handler(BaseHTTPRequestHandler):
name = payload.get("name") name = payload.get("name")
if not isinstance(name, str) or not name: if not isinstance(name, str) or not name:
return self._json(400, {"error": "missing or empty 'name' field"}) return self._json(400, {"error": "missing or empty 'name' field"})
if not _valid_app_name(name):
return self._json(400, {"error": "invalid app name"})
if self.path == "/api/apps/install/plan": if self.path == "/api/apps/install/plan":
status, body = _do_install_plan(name) status, body = _do_install_plan(name)
@ -1863,7 +1962,8 @@ class _Handler(BaseHTTPRequestHandler):
def serve(host: str = "127.0.0.1", port: int = 7000) -> None: def serve(host: str = "127.0.0.1", port: int = 7000) -> None:
"""Run the API server. Blocks forever; exits on SIGINT/SIGTERM.""" """Run the API server. Blocks forever; exits on SIGINT/SIGTERM."""
server = HTTPServer((host, port), _Handler) server = ThreadingHTTPServer((host, port), _Handler)
server.daemon_threads = True
print(f"Furtka API listening on http://{host}:{port}/") print(f"Furtka API listening on http://{host}:{port}/")
try: try:
server.serve_forever() server.serve_forever()

View file

@ -23,6 +23,7 @@ from __future__ import annotations
import json import json
import math import math
import os
import secrets import secrets
import threading import threading
from dataclasses import dataclass from dataclasses import dataclass
@ -46,27 +47,33 @@ def verify_password(plain: str, hashed: str) -> bool:
return _verify_password(plain, hashed) return _verify_password(plain, hashed)
class UsersFileError(RuntimeError):
"""users.json exists but can't be read or parsed."""
def load_users() -> dict: def load_users() -> dict:
"""Return the users dict, or {} if the file is missing or empty. """Return the users dict, or {} if the file is missing.
Missing-file is the expected state on first boot and on upgrades from Missing-file is the expected state on first boot and on upgrades from
pre-auth versions — callers treat empty-dict as "setup required". pre-auth versions — callers treat empty-dict as "setup required".
A file that exists but is unreadable, empty or corrupt raises
UsersFileError instead: treating it as "no admin yet" would turn /login
into an open "create admin" form for anyone on the network.
""" """
path = users_file() path = users_file()
if not path.exists():
return {}
try: try:
raw = path.read_text() raw = path.read_text()
except OSError: except FileNotFoundError:
return {}
if not raw.strip():
return {} return {}
except (OSError, UnicodeDecodeError) as e:
raise UsersFileError(f"{path} is unreadable: {e}") from e
try: try:
data = json.loads(raw) data = json.loads(raw)
except json.JSONDecodeError: except json.JSONDecodeError as e:
return {} raise UsersFileError(f"{path} is corrupt: {e}") from e
if not isinstance(data, dict): if not isinstance(data, dict):
return {} raise UsersFileError(f"{path} is corrupt: not a JSON object")
return data return data
@ -80,7 +87,12 @@ def save_users(users: dict) -> None:
path = users_file() path = users_file()
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp") tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(users, indent=2) + "\n") with open(tmp, "w") as fh:
fh.write(json.dumps(users, indent=2) + "\n")
# A power cut right after rename() can otherwise leave a zero-length
# users.json, which load_users() refuses — locking the owner out.
fh.flush()
os.fsync(fh.fileno())
tmp.chmod(0o600) tmp.chmod(0o600)
tmp.replace(path) tmp.replace(path)

View file

@ -221,8 +221,24 @@ def _atomic_swap(staging: Path) -> None:
def sync_catalog() -> CatalogCheck: def sync_catalog() -> CatalogCheck:
"""End-to-end sync. Acquires the lock, writes state at each stage, and """End-to-end sync. Acquires the lock, writes state at each stage, and
leaves the live catalog untouched on any failure before the rename step. leaves the live catalog untouched on any failure before the rename step.
A failure lands as stage ``"error"`` (with the message) instead of leaving
the last in-flight stage behind: the first hardware bench sat on
``"checking"`` for hours after a boot-time network failure, and the UI
showed "checking…" with nothing actually happening.
""" """
with acquire_lock(): with acquire_lock():
try:
return _sync_locked()
except CatalogError as e:
write_state("error", error=str(e))
raise
except Exception as e:
write_state("error", error=str(e) or type(e).__name__)
raise CatalogError(str(e) or type(e).__name__) from e
def _sync_locked() -> CatalogCheck:
write_state("checking") write_state("checking")
check = check_catalog() check = check_catalog()
if not check.update_available: if not check.update_available:

View file

@ -73,37 +73,62 @@ def _cmd_app_list(args: argparse.Namespace) -> int:
def _cmd_app_install(args: argparse.Namespace) -> int: def _cmd_app_install(args: argparse.Namespace) -> int:
# If the user passed a path (or a path-ish thing), bypass dep resolution — # If the user passed a path (or a path-ish thing), bypass dep resolution —
# local paths are dev/test workflows where the caller knows what they want. # local paths are dev/test workflows where the caller knows what they want.
# Catalog/bundled name installs go through plan_install() so transitive # These just copy + reconcile; no `requires`/hook handling.
# `requires` are pulled in.
src_path = Path(args.source) src_path = Path(args.source)
is_path = src_path.is_dir() or "/" in args.source or args.source.startswith(".") is_path = src_path.is_dir() or "/" in args.source or args.source.startswith(".")
try:
if is_path: if is_path:
try:
src = installer.resolve_source(args.source) src = installer.resolve_source(args.source)
target = installer.install_from(src) target = installer.install_from(src)
except installer.InstallError as e:
print(f"error: {e}", file=sys.stderr)
return 2
print(f"installed {target.name} to {target}") print(f"installed {target.name} to {target}")
else: actions = reconciler.reconcile(apps_dir())
for a in actions:
print(f" {a.describe()}")
return 1 if reconciler.has_errors(actions) else 0
# Catalog/bundled name install: resolve transitive `requires`, copy every
# app folder (the synchronous phase the API runs inline), then drive the
# exact same docker phase the API dispatches via systemd-run — so providers
# come up before consumers and `on_install` hooks fire to provision them.
# Going through run_install (not a bare reconcile) is what makes a CLI
# install of a consumer actually provision against its provider.
from furtka import install_runner
try: try:
plan = deps.plan_install(args.source) plan = deps.plan_install(args.source)
except deps.DependencyError as e: except deps.DependencyError as e:
print(f"error: {e}", file=sys.stderr) print(f"error: {e}", file=sys.stderr)
return 2 return 2
if not plan.to_install: try:
# Target is already installed — re-run as a single-app install if plan.to_install:
# to refresh files (matches reinstall semantics). installer.install_plan(plan)
target_path = installer.install_from(installer.resolve_source(args.source)) to_install = list(plan.to_install)
print(f"reinstalled {target_path.name} to {target_path}")
else: else:
targets = installer.install_plan(plan) # Target already installed — reinstall to refresh files, then still
for t in targets: # run the docker phase so hooks re-fire (matches API reinstall).
print(f"installed {t.name} to {t}") installer.install_from(installer.resolve_source(args.source))
to_install = [args.source]
except installer.InstallError as e: except installer.InstallError as e:
print(f"error: {e}", file=sys.stderr) print(f"error: {e}", file=sys.stderr)
return 2 return 2
actions = reconciler.reconcile(apps_dir())
for a in actions: # Stage the plan file run_install consumes (it removes it after reading).
print(f" {a.describe()}") install_runner.plan_path().parent.mkdir(parents=True, exist_ok=True)
return 1 if reconciler.has_errors(actions) else 0 install_runner.plan_path().write_text(
json.dumps({"target": args.source, "to_install": to_install})
)
try:
install_runner.run_install(args.source)
except Exception as e:
# run_install already wrote state="error"; surface it to the caller.
print(f"error: {e}", file=sys.stderr)
return 1
for name in to_install:
print(f"installed {name}")
return 0
def _cmd_app_install_bg(args: argparse.Namespace) -> int: def _cmd_app_install_bg(args: argparse.Namespace) -> int:

View file

@ -91,9 +91,53 @@ def write_state(stage: str, **extra) -> None:
def read_state() -> dict: def read_state() -> dict:
try: try:
return json.loads(state_path().read_text()) data = json.loads(state_path().read_text())
except (FileNotFoundError, json.JSONDecodeError): except (OSError, UnicodeDecodeError, json.JSONDecodeError):
return {} return {}
return data if isinstance(data, dict) else {}
TERMINAL_STAGES = frozenset({"done", "error"})
# How old an unfinished state must be before we believe the job is gone.
# Covers the gap between the API writing the first stage and the detached
# runner taking the lock.
_STALE_AFTER_SECONDS = 60
def clear_stale_state(now: float | None = None) -> bool:
"""Mark an unfinished install as failed if no runner is alive any more.
install-state.json lives in /var/lib and survives reboots, OOM kills and
a runner that never started — each of which used to block every later
install with "another install is in progress" until someone fixed it
over SSH. The runner holds the flock for its whole run, so a free lock
plus an old timestamp means nobody is coming back to finish. Returns
True when it reset the state.
"""
state = read_state()
stage = state.get("stage", "")
if not stage or stage in TERMINAL_STAGES:
return False
try:
updated = time.mktime(time.strptime(state.get("updated_at", ""), "%Y-%m-%dT%H:%M:%S%z"))
except (TypeError, ValueError):
updated = 0.0
if (now if now is not None else time.time()) - updated < _STALE_AFTER_SECONDS:
return False
try:
fh = acquire_lock()
except (InstallRunnerError, OSError):
return False # a runner is still working
try:
write_state(
"error",
app=state.get("app", ""),
error=f"the install was interrupted at '{stage}' (restart or crash) — try again",
)
finally:
fh.close()
return True
def _read_plan(target: str) -> dict: def _read_plan(target: str) -> dict:

View file

@ -2,7 +2,7 @@ import shutil
from pathlib import Path from pathlib import Path
from furtka import sources from furtka import sources
from furtka.manifest import Manifest, ManifestError, load_manifest from furtka.manifest import APP_NAME_RE, Manifest, ManifestError, load_manifest
from furtka.paths import apps_dir from furtka.paths import apps_dir
# Values that an app's .env.example may use as obvious "fill me in" markers. # Values that an app's .env.example may use as obvious "fill me in" markers.
@ -35,19 +35,26 @@ class InstallError(RuntimeError):
pass pass
def app_dir(name: str) -> Path:
"""/var/lib/furtka/apps/<name>, refusing anything that isn't a plain app name.
Names reach us from API bodies and URL paths; without this check "..",
"." or "/" would point remove()'s rmtree at /var/lib/furtka, every app,
or the root filesystem.
"""
if not isinstance(name, str) or not APP_NAME_RE.fullmatch(name):
raise InstallError(f"{name!r} is not a valid app name")
return apps_dir() / name
def _placeholder_values(values: dict[str, str]) -> list[str]:
return [k for k, v in values.items() if v.strip() in PLACEHOLDER_SECRETS]
def _placeholder_keys(env_path: Path) -> list[str]: def _placeholder_keys(env_path: Path) -> list[str]:
if not env_path.exists(): if not env_path.exists():
return [] return []
bad: list[str] = [] return _placeholder_values(_read_env(env_path))
for raw in env_path.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
value = value.strip().strip('"').strip("'")
if value in PLACEHOLDER_SECRETS:
bad.append(key.strip())
return bad
def _is_denied_system_path(resolved: str) -> bool: def _is_denied_system_path(resolved: str) -> bool:
@ -59,16 +66,13 @@ def _is_denied_system_path(resolved: str) -> bool:
return False return False
def _path_setting_errors(m: Manifest, env_path: Path) -> list[str]: def _path_setting_errors(m: Manifest, values: dict[str, str]) -> list[str]:
"""Validate the filesystem paths named by `path`-type settings. """Validate the filesystem paths named by `path`-type settings.
Returns one human-readable message per offending setting. Empty values Returns one human-readable message per offending setting. Empty values
on non-required settings are allowed — the required-field check in the on non-required settings are allowed — the required-field check in the
caller already refuses blanks on required fields before write. caller already refuses blanks on required fields before write.
""" """
if not env_path.exists():
return []
values = _read_env(env_path)
errors: list[str] = [] errors: list[str] = []
for s in m.settings: for s in m.settings:
if s.type != "path": if s.type != "path":
@ -97,10 +101,35 @@ def _path_setting_errors(m: Manifest, env_path: Path) -> list[str]:
return errors return errors
def _validate_settings(m: Manifest, values: dict[str, str]) -> None:
"""Refuse a merged settings view before it is written to .env.
Runs in memory so a rejected value (placeholder, system path, line
break) never reaches the live .env where the next reconcile would use it.
"""
for k, v in values.items():
if any(c in v for c in "\r\n\0"):
raise InstallError(f"{m.name}: setting {k!r} must not contain line breaks")
bad = _placeholder_values({s.name: values.get(s.name, "") for s in m.settings})
if bad:
raise InstallError(
f"{m.name}: please replace the example value for {', '.join(bad)} with your own"
)
path_errors = _path_setting_errors(m, values)
if path_errors:
raise InstallError(f"{m.name}: {'; '.join(path_errors)}")
def _format_env_value(v: str) -> str: def _format_env_value(v: str) -> str:
# Quote values that contain whitespace, quotes, or shell metacharacters so # Quote values that contain whitespace, quotes, or shell metacharacters so
# docker-compose's env substitution reads them back intact. Simple values # docker-compose's env substitution reads them back intact. Simple values
# stay unquoted to keep the file readable when a user SSHes in. # stay unquoted to keep the file readable when a user SSHes in.
if any(c in v for c in "\r\n\0"):
raise InstallError("setting values must not contain line breaks")
if "$" in v and "'" not in v:
# Compose expands ${VAR} inside double quotes, so "pa$word" would
# silently turn into "pa". Single-quoted values are taken literally.
return f"'{v}'"
if v == "" or any(c in v for c in " \t\"'$`\\#"): if v == "" or any(c in v for c in " \t\"'$`\\#"):
escaped = v.replace("\\", "\\\\").replace('"', '\\"') escaped = v.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"' return f'"{escaped}"'
@ -167,17 +196,26 @@ def install_from(src: Path, settings: dict[str, str] | None = None) -> Path:
if key not in declared: if key not in declared:
raise InstallError(f"{m.name}: unknown setting {key!r}") raise InstallError(f"{m.name}: unknown setting {key!r}")
target = apps_dir() / m.name target = app_dir(m.name)
target.mkdir(parents=True, exist_ok=True) target.mkdir(parents=True, exist_ok=True)
for item in src.iterdir(): for item in src.iterdir():
dest = target / item.name
# Subdirectories (e.g. scripts/ holding a provider's on_install/on_start
# dependency hooks) must come along too — copy the whole tree, replacing
# any stale copy from a previous install so removed files don't linger.
if item.is_dir():
if dest.exists():
shutil.rmtree(dest)
shutil.copytree(item, dest)
continue
if not item.is_file(): if not item.is_file():
continue continue
# Never overwrite an existing user .env — either settings-driven write # Never overwrite an existing user .env — either settings-driven write
# or previous manual edit has authority. # or previous manual edit has authority.
if item.name == ".env" and (target / ".env").exists(): if item.name == ".env" and (target / ".env").exists():
continue continue
shutil.copy2(item, target / item.name) shutil.copy2(item, dest)
env = target / ".env" env = target / ".env"
env_example = target / ".env.example" env_example = target / ".env.example"
@ -207,6 +245,7 @@ def install_from(src: Path, settings: dict[str, str] | None = None) -> Path:
for s in m.settings: for s in m.settings:
if s.required and not merged.get(s.name): if s.required and not merged.get(s.name):
raise InstallError(f"{m.name}: setting {s.name!r} is required") raise InstallError(f"{m.name}: setting {s.name!r} is required")
_validate_settings(m, merged)
write_env(env, merged) write_env(env, merged)
elif not env.exists() and env_example.exists(): elif not env.exists() and env_example.exists():
# First install with no settings and no .env shipped: bootstrap from # First install with no settings and no .env shipped: bootstrap from
@ -226,7 +265,7 @@ def install_from(src: Path, settings: dict[str, str] | None = None) -> Path:
f"file and re-run `furtka app install {m.name}`." f"file and re-run `furtka app install {m.name}`."
) )
path_errors = _path_setting_errors(m, env) path_errors = _path_setting_errors(m, read_env_values(env))
if path_errors: if path_errors:
raise InstallError(f"{m.name}: {'; '.join(path_errors)}") raise InstallError(f"{m.name}: {'; '.join(path_errors)}")
@ -247,10 +286,11 @@ def parse_env_text(text: str) -> dict[str, str]:
continue continue
key, _, value = line.partition("=") key, _, value = line.partition("=")
value = value.strip() value = value.strip()
if (value.startswith('"') and value.endswith('"')) or ( if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
value.startswith("'") and value.endswith("'")
):
value = value[1:-1].replace('\\"', '"').replace("\\\\", "\\") value = value[1:-1].replace('\\"', '"').replace("\\\\", "\\")
elif len(value) >= 2 and value.startswith("'") and value.endswith("'"):
# Single quotes are literal in compose's dotenv — no escapes.
value = value[1:-1]
out[key.strip()] = value out[key.strip()] = value
return out return out
@ -275,7 +315,7 @@ def update_env(name: str, settings: dict[str, str]) -> Path:
apps only. Returns the target folder; caller is expected to run apps only. Returns the target folder; caller is expected to run
reconcile to restart the containers. reconcile to restart the containers.
""" """
target = apps_dir() / name target = app_dir(name)
manifest_path = target / "manifest.json" manifest_path = target / "manifest.json"
if not manifest_path.exists(): if not manifest_path.exists():
raise InstallError(f"{name!r} is not installed") raise InstallError(f"{name!r} is not installed")
@ -307,13 +347,8 @@ def update_env(name: str, settings: dict[str, str]) -> Path:
for s in m.settings: for s in m.settings:
if s.required and not merged.get(s.name): if s.required and not merged.get(s.name):
raise InstallError(f"{m.name}: setting {s.name!r} is required") raise InstallError(f"{m.name}: setting {s.name!r} is required")
_validate_settings(m, merged)
write_env(env, merged) write_env(env, merged)
bad = _placeholder_keys(env)
if bad:
raise InstallError(f"{m.name}: {env} still has placeholder values for {', '.join(bad)}.")
path_errors = _path_setting_errors(m, env)
if path_errors:
raise InstallError(f"{m.name}: {'; '.join(path_errors)}")
return target return target
@ -344,7 +379,7 @@ def remove(name: str) -> Path:
Caller is responsible for stopping the compose project first. Caller is responsible for stopping the compose project first.
""" """
target = apps_dir() / name target = app_dir(name)
if not target.exists(): if not target.exists():
raise InstallError(f"{name!r} is not installed") raise InstallError(f"{name!r} is not installed")
shutil.rmtree(target) shutil.rmtree(target)

View file

@ -212,8 +212,10 @@ def load_manifest(path: Path, expected_name: str | None = None) -> Manifest:
raise ManifestError(f"{path}: missing required fields: {', '.join(missing)}") raise ManifestError(f"{path}: missing required fields: {', '.join(missing)}")
name = raw["name"] name = raw["name"]
if not isinstance(name, str) or not name: if not isinstance(name, str) or not APP_NAME_RE.fullmatch(name):
raise ManifestError(f"{path}: name must be a non-empty string") raise ManifestError(
f"{path}: name must be lowercase letters, digits, '-' or '_' (got {name!r})"
)
if expected_name is not None and name != expected_name: if expected_name is not None and name != expected_name:
raise ManifestError(f"{path}: name {name!r} must equal {expected_name!r}") raise ManifestError(f"{path}: name {name!r} must equal {expected_name!r}")

View file

@ -29,6 +29,7 @@ the updater at a tmpdir.
from __future__ import annotations from __future__ import annotations
import fcntl import fcntl
import http.client
import json import json
import os import os
import shutil import shutil
@ -226,12 +227,17 @@ def _link_new_units(unit_dir: Path) -> list[str]:
"""`systemctl link` any unit file in unit_dir that isn't already symlinked """`systemctl link` any unit file in unit_dir that isn't already symlinked
into /etc/systemd/system/. Returns the list of newly-linked unit names. into /etc/systemd/system/. Returns the list of newly-linked unit names.
Newly-linked `.timer` units are additionally `systemctl enable`d so that Newly-linked `.timer` units are additionally `systemctl enable`d *and
a self-update introducing a timer (e.g. 26.5 → 26.6 adding started* so that a self-update introducing a timer (e.g. 26.5 → 26.6
furtka-catalog-sync.timer) activates it automatically — the installer's adding furtka-catalog-sync.timer, 26.19 → 26.20 adding
enable list only applies to fresh installs. A linked-but-disabled timer furtka-welcome.timer) activates it right away — the installer's enable
never fires on its own, so without this step catalog sync would never list only applies to fresh installs, and `enable` alone only takes
happen on upgraded boxes. effect at the next boot (the bench sat on a dead welcome timer after the
26.20 update).
Units are linked through the `/opt/furtka/current` symlink, not the
versioned directory that `unit_dir` scans, so the link keeps following
later updates and rollbacks the same way the installer-created links do.
""" """
if not unit_dir.is_dir(): if not unit_dir.is_dir():
return [] return []
@ -242,9 +248,10 @@ def _link_new_units(unit_dir: Path) -> list[str]:
target = _SYSTEMD_DIR / unit_file.name target = _SYSTEMD_DIR / unit_file.name
if target.exists() or target.is_symlink(): if target.exists() or target.is_symlink():
continue continue
_run(["systemctl", "link", str(unit_file)]) _run(["systemctl", "link", str(current_symlink() / "assets" / "systemd" / unit_file.name)])
if unit_file.suffix == ".timer": if unit_file.suffix == ".timer":
_run(["systemctl", "enable", unit_file.name]) _run(["systemctl", "enable", unit_file.name])
_run(["systemctl", "start", unit_file.name])
linked.append(unit_file.name) linked.append(unit_file.name)
return linked return linked
@ -259,9 +266,10 @@ def write_state(stage: str, **extra) -> None:
def read_state() -> dict: def read_state() -> dict:
try: try:
return json.loads(state_path().read_text()) data = json.loads(state_path().read_text())
except (FileNotFoundError, json.JSONDecodeError): except (OSError, UnicodeDecodeError, json.JSONDecodeError):
return {} return {}
return data if isinstance(data, dict) else {}
def acquire_lock(): def acquire_lock():
@ -312,13 +320,29 @@ def _health_check(url: str, deadline_s: float = 30.0) -> bool:
# check. 5xx → genuinely broken, don't accept. # check. 5xx → genuinely broken, don't accept.
if 400 <= e.code < 500: if 400 <= e.code < 500:
return True return True
except urllib.error.URLError: except (OSError, http.client.HTTPException):
# Connection refused / DNS / timeout → not up yet, retry. # Connection refused / reset / timeout (URLError is an OSError;
# a read timeout or RemoteDisconnected is not wrapped by urllib)
# → not up yet, retry.
pass pass
time.sleep(1) time.sleep(1)
return False return False
def _swap_symlink(link: Path, target: str | Path) -> None:
"""Point `link` at `target` atomically.
unlink() + symlink_to() leaves a window with no /opt/furtka/current at
all — a power cut there boots a box with no services. rename() over the
old link is atomic.
"""
tmp = link.with_name(link.name + ".new")
if tmp.is_symlink() or tmp.exists():
tmp.unlink()
tmp.symlink_to(target)
os.replace(tmp, link)
def prepare_update(check: UpdateCheck, download_dir: Path | None = None) -> tuple[Path, str]: def prepare_update(check: UpdateCheck, download_dir: Path | None = None) -> tuple[Path, str]:
"""Download + verify the tarball. Returns (tarball_path, version).""" """Download + verify the tarball. Returns (tarball_path, version)."""
if not check.tarball_url or not check.sha256_url: if not check.tarball_url or not check.sha256_url:
@ -363,15 +387,11 @@ def apply_update(tarball: Path, version: str) -> None:
target.chmod(0o755) target.chmod(0o755)
write_state("swapping", latest=version) write_state("swapping", latest=version)
previous = None previous = os.readlink(current) if current.is_symlink() else None
if current.is_symlink(): old_caddyfile = _CADDYFILE_LIVE.read_text() if _CADDYFILE_LIVE.is_file() else None
previous = os.readlink(current)
current.unlink()
try: try:
current.symlink_to(target) _swap_symlink(current, target)
except OSError as e: except OSError as e:
if previous:
current.symlink_to(previous)
raise UpdateError(f"symlink swap failed: {e}") from e raise UpdateError(f"symlink swap failed: {e}") from e
write_state("restarting", latest=version) write_state("restarting", latest=version)
@ -387,27 +407,41 @@ def apply_update(tarball: Path, version: str) -> None:
_run(["systemctl", "daemon-reload"]) _run(["systemctl", "daemon-reload"])
_run(["systemctl", "try-restart", "furtka-reconcile.service"]) _run(["systemctl", "try-restart", "furtka-reconcile.service"])
_run(["systemctl", "restart", "furtka-api.service"]) _run(["systemctl", "restart", "furtka-api.service"])
except UpdateError as e: except (UpdateError, OSError) as e:
_rollback(previous, version, f"service restart failed: {e}") # OSError: e.g. disk full or EACCES writing the Caddyfile — the new
raise # version is already live, so this must roll back too.
_rollback(previous, version, f"service restart failed: {e}", old_caddyfile)
raise UpdateError(f"service restart failed: {e} — rolled back") from e
write_state("verifying", latest=version) write_state("verifying", latest=version)
ok = _health_check("http://127.0.0.1:7000/api/apps", deadline_s=30.0) ok = _health_check("http://127.0.0.1:7000/api/apps", deadline_s=30.0)
if not ok: if not ok:
_rollback(previous, version, "health check failed after restart") _rollback(previous, version, "health check failed after restart", old_caddyfile)
raise UpdateError("new version failed health check — rolled back") raise UpdateError("new version failed health check — rolled back")
write_state("done", version=version) write_state("done", version=version)
def _rollback(previous_target: str | None, failed_version: str, reason: str) -> None: def _rollback(
previous_target: str | None,
failed_version: str,
reason: str,
old_caddyfile: str | None = None,
) -> None:
current = current_symlink() current = current_symlink()
if previous_target: if previous_target:
if current.is_symlink(): _swap_symlink(current, previous_target)
current.unlink() # The new release may have shipped Caddy routes the old code
current.symlink_to(previous_target) # doesn't serve; put the previous Caddyfile back before reloading.
if old_caddyfile is not None:
try:
_CADDYFILE_LIVE.write_text(old_caddyfile)
except OSError:
pass
# Best-effort restart on the previous target — if it fails too the # Best-effort restart on the previous target — if it fails too the
# box is in a hard state, but we can only surface the reason. # box is in a hard state, but we can only surface the reason.
subprocess.run(["systemctl", "daemon-reload"], check=False)
subprocess.run(["systemctl", "reload", "caddy"], check=False)
subprocess.run(["systemctl", "restart", "furtka-api.service"], check=False) subprocess.run(["systemctl", "restart", "furtka-api.service"], check=False)
write_state( write_state(
"rolled_back", "rolled_back",
@ -424,6 +458,7 @@ def run_update() -> UpdateCheck:
UpdateError on any failure; the state file records the stage. UpdateError on any failure; the state file records the stage.
""" """
with acquire_lock(): with acquire_lock():
try:
check = check_update() check = check_update()
if not check.update_available: if not check.update_available:
write_state("done", version=check.current, note="already up to date") write_state("done", version=check.current, note="already up to date")
@ -431,6 +466,17 @@ def run_update() -> UpdateCheck:
tarball, version = prepare_update(check) tarball, version = prepare_update(check)
apply_update(tarball, version) apply_update(tarball, version)
return check return check
except Exception as e:
# Without a terminal stage the settings page keeps showing
# "Downloading…" forever. Rollback already wrote its own stage.
if read_state().get("stage") != "rolled_back":
try:
write_state("error", reason=str(e) or type(e).__name__)
except OSError:
pass
if isinstance(e, UpdateError):
raise
raise UpdateError(str(e) or type(e).__name__) from e
def rollback() -> str: def rollback() -> str:
@ -450,8 +496,7 @@ def rollback() -> str:
raise UpdateError("no other version slots available to roll back to") raise UpdateError("no other version slots available to roll back to")
target_name = candidates[0] target_name = candidates[0]
target = versions_dir() / target_name target = versions_dir() / target_name
current.unlink() _swap_symlink(current, target)
current.symlink_to(target)
subprocess.run(["systemctl", "daemon-reload"], check=False) subprocess.run(["systemctl", "daemon-reload"], check=False)
subprocess.run(["systemctl", "restart", "furtka-api.service"], check=False) subprocess.run(["systemctl", "restart", "furtka-api.service"], check=False)
write_state("rolled_back_manual", restored_to=target_name) write_state("rolled_back_manual", restored_to=target_name)

View file

@ -27,13 +27,15 @@ The build starts from Arch's stock `releng` profile (the same one used to build
| `overlay/airootfs/opt/furtka/` | Directory where `webinstaller/` is copied at build time | | `overlay/airootfs/opt/furtka/` | Directory where `webinstaller/` is copied at build time |
| `overlay/airootfs/etc/hostname` | Live-ISO hostname (`proksi`) so mDNS advertises the installer as `proksi.local` | | `overlay/airootfs/etc/hostname` | Live-ISO hostname (`proksi`) so mDNS advertises the installer as `proksi.local` |
| `overlay/airootfs/etc/issue` | Welcome banner on the TTY pointing users at `http://proksi.local:5000` | | `overlay/airootfs/etc/issue` | Welcome banner on the TTY pointing users at `http://proksi.local:5000` |
| `overlay/airootfs/usr/local/bin/furtka-update-issue` | Rewrites `/etc/issue` at runtime so the banner also shows the DHCP-assigned IP as a fallback URL | | `overlay/airootfs/usr/local/bin/furtka-update-issue` | Rewrites `/etc/issue` at runtime so the banner always carries a fallback line: the DHCP-assigned IP(s), or a "no IP yet — check cable / DHCP" hint until one arrives. Only touches the file (and `agetty --reload`s) when the text changed |
| `overlay/airootfs/etc/systemd/system/` | `furtka-webinstaller.service` (Flask on :5000) + `furtka-issue.service` (runs the banner-updater on network-online), each symlinked into `multi-user.target.wants/` to auto-start on boot | | `overlay/airootfs/etc/systemd/system/` | `furtka-webinstaller.service` (Flask on :5000) + `furtka-issue.service` (banner-updater, first run at boot) in `multi-user.target.wants/`, and `furtka-issue.timer` (re-runs the updater every 5 s) in `timers.target.wants/` |
The systemd service runs `flask --app app run --host 0.0.0.0 --port 5000` under `/opt/furtka`. The `0.0.0.0` binding is important — the Flask default is localhost-only, which wouldn't be reachable from another machine on the LAN. The systemd service runs `flask --app app run --host 0.0.0.0 --port 5000` under `/opt/furtka`. The `0.0.0.0` binding is important — the Flask default is localhost-only, which wouldn't be reachable from another machine on the LAN.
mDNS is wired: `avahi-daemon` + `nss-mdns` come from `packages.extra`, the live ISO's hostname is `proksi`, and as soon as `systemd-networkd-wait-online` fires the installer is reachable at `http://proksi.local:5000`. The raw IP still shows on the console for fallback — some Windows clients need the Bonjour service for `.local` to resolve at all. mDNS is wired: `avahi-daemon` + `nss-mdns` come from `packages.extra`, the live ISO's hostname is `proksi`, and as soon as `systemd-networkd-wait-online` fires the installer is reachable at `http://proksi.local:5000`. The raw IP still shows on the console for fallback — some Windows clients need the Bonjour service for `.local` to resolve at all.
`build.sh` also deletes releng's `getty@tty1.service.d/autologin.conf`. With root auto-logged-in, tty1 belongs to a shell and `agetty --reload` can't redraw the banner, so the IP line was a snapshot of whatever DHCP had done by the time getty started. Without autologin tty1 sits at the login prompt showing the live banner; for a shell type `root` + Enter (releng's root has no password) — the banner says so.
## Test flow ## Test flow
1. Build: `./iso/build.sh` 1. Build: `./iso/build.sh`
@ -49,6 +51,27 @@ mDNS is wired: `avahi-daemon` + `nss-mdns` come from `packages.extra`, the live
5. Find its IP in Proxmox's VM summary (or your router's DHCP table) 5. Find its IP in Proxmox's VM summary (or your router's DHCP table)
6. Open `http://<vm-ip>:5000` — the existing 3-screen wizard should be there 6. Open `http://<vm-ip>:5000` — the existing 3-screen wizard should be there
## Real hardware (what differs from the VM flow)
Learned on the first bare-metal run, see [docs/hardware-test-2026-08.md](../docs/hardware-test-2026-08.md):
- **BIOS boxes work.** The hybrid ISO boots via syslinux on legacy firmware
(you'll see the stock Arch splash — only the menu labels are branded), and
the installer detects the missing `/sys/firmware/efi` and picks GRUB + MBR.
- **Pull the USB stick before "restart now".** Most BIOSes boot USB before
the disk; the done page says so above the button since 26.20-alpha.
- **Disks with a previous OS are fine since 26.20-alpha.** The live ISO
auto-activates old LVM/RAID/swap; `webinstaller/diskprep.py` releases the
disk before archinstall partitions it. On 26.19 and earlier you'd hit
`unable to inform the kernel of the change … in use` at 12 % — clear it
from the tty1 root shell with `dmsetup remove <node>` for every dm node
shown by `lsblk`, then `wipefs -a /dev/sdX` and retry.
- **Slow NICs.** The console banner's IP fallback line is timer-driven
since 26.20-alpha; on 26.19 it only appeared if DHCP beat getty.
- **Shell on the live ISO**: since 26.20-alpha tty1 stays at the login
prompt (banner refreshes live); log in as `root`, no password. sshd runs;
set a root password with `passwd` to reach it over the network.
## What you see after install + reboot ## What you see after install + reboot
Once `archinstall` finishes and you click **Reboot now**, the VM comes up into the installed system. No more port `:5000` — the wizard ISO is gone. Instead: Once `archinstall` finishes and you click **Reboot now**, the VM comes up into the installed system. No more port `:5000` — the wizard ISO is gone. Instead:

View file

@ -45,7 +45,19 @@ cat "$SCRIPT_DIR/overlay/profiledef.sh" >> "$PROFILE_WORK/profiledef.sh"
cp -a "$SCRIPT_DIR/overlay/airootfs/." "$PROFILE_WORK/airootfs/" cp -a "$SCRIPT_DIR/overlay/airootfs/." "$PROFILE_WORK/airootfs/"
# releng auto-logs root in on tty1. That turns our /etc/issue banner into a
# one-shot snapshot: once agetty has handed tty1 to a shell, `agetty --reload`
# from furtka-update-issue has nothing left to redraw, so the IP fallback only
# shows if DHCP happened to beat getty. Drop the drop-in: tty1 stays at the
# login prompt with a live-refreshed banner (furtka-issue.timer), and a shell
# is one `root` + Enter away (releng's root has no password) — the banner says
# so.
rm -rf "$PROFILE_WORK/airootfs/etc/systemd/system/getty@tty1.service.d"
echo "==> Rebranding boot menu (GRUB + syslinux + systemd-boot)" echo "==> Rebranding boot menu (GRUB + syslinux + systemd-boot)"
# BIOS boot menu background: Furtka lockup instead of the Arch logo
# (generated by website/tools/brand-assets.py, 640x480 as syslinux wants).
cp "$SCRIPT_DIR/overlay/splash.png" "$PROFILE_WORK/syslinux/splash.png"
# releng ships menu entries labelled "Arch Linux install medium" across three # releng ships menu entries labelled "Arch Linux install medium" across three
# bootloader configs (BIOS syslinux, GRUB, systemd-boot for UEFI). Rewrite to # bootloader configs (BIOS syslinux, GRUB, systemd-boot for UEFI). Rewrite to
# our brand. Done with sed (not a static overlay) so upstream archiso file # our brand. Done with sed (not a static overlay) so upstream archiso file

View file

@ -2,5 +2,6 @@
Furtka Live Installer starting… Furtka Live Installer starting…
Once ready, open http://proksi.local:5000 on another device Once ready, open http://proksi.local:5000 on another device
on your network. The exact URL will appear below. on your network. The exact URL (and an IP fallback) will
appear here in a few seconds.

View file

@ -1,12 +1,12 @@
[Unit] [Unit]
Description=Write Furtka /etc/issue with current IP for the console welcome Description=Write Furtka /etc/issue with current IP for the console welcome
After=network-online.target # No network-online dependency on purpose: the first run should paint the
Wants=network-online.target # banner (with a "no IP yet" fallback) as early as possible; the timer
# re-runs it until an address shows up.
[Service] [Service]
Type=oneshot Type=oneshot
ExecStart=/usr/local/bin/furtka-update-issue ExecStart=/usr/local/bin/furtka-update-issue
RemainAfterExit=yes
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View file

@ -0,0 +1,10 @@
[Unit]
Description=Keep the Furtka console welcome (IP fallback) up to date
[Timer]
OnBootSec=3s
OnUnitActiveSec=5s
AccuracySec=1s
[Install]
WantedBy=timers.target

View file

@ -0,0 +1 @@
../furtka-issue.timer

View file

@ -2,22 +2,53 @@
# Regenerates /etc/issue so the live-ISO console tells the user which URL # Regenerates /etc/issue so the live-ISO console tells the user which URL
# to open in their browser. Shows proksi.local (via avahi/mDNS) as the # to open in their browser. Shows proksi.local (via avahi/mDNS) as the
# preferred URL and the raw IP as a fallback for networks where mDNS # preferred URL and the raw IP as a fallback for networks where mDNS
# doesn't work. Reload at the end nudges agetty to redraw. # doesn't work.
set -e #
# Runs at boot and then every few seconds from furtka-issue.timer, so the
# fallback line is *always* on screen: with the address once DHCP has
# handed one out, or with a "no IP yet" hint before that / when the cable
# is unplugged. Only rewrites + nudges agetty when the text actually
# changed, so the login prompt doesn't flicker on every tick.
set -u
ip=$(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1) issue=/etc/issue
{ mapfile -t ips < <(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1)
new=$(
# Furtka mark (docs/brand/console-banner.txt, from brand-assets.py)
echo ' ▄▄▄▄▄▄▄▄'
echo ' ▄██████████▄'
echo ' █████▀▀▀▀▀▀███'
echo ' ████▀ ████'
echo ' ████ █████████'
echo ' ████ ████'
echo ' ████ ▄▄▄▄▄████'
echo ' ████ █████████'
echo ' ████ █████████'
echo ' ▀▀▀▀ ▀▀▀▀▀▀▀▀▀'
echo echo
echo " Open Furtka in a browser on another device on your network:" echo " Open Furtka in a browser on another device on your network:"
echo echo
echo " http://proksi.local:5000 (easy — try this first)" echo " http://proksi.local:5000 (easy — try this first)"
if [ -n "$ip" ]; then if ((${#ips[@]})); then
for ip in "${ips[@]}"; do
echo " http://${ip}:5000 (fallback if the first doesn't work)" echo " http://${ip}:5000 (fallback if the first doesn't work)"
done
else
echo " (fallback: no IP address yet — waiting for the network."
echo " Check the cable / DHCP. This line updates by itself.)"
fi fi
echo echo
echo " Then follow the wizard to install Furtka on this machine." echo " Then follow the wizard to install Furtka on this machine."
echo " The install downloads packages, so this machine needs a network"
echo " cable with internet access. Wi-Fi is not set up automatically."
echo echo
} > /etc/issue echo " Need a shell? Log in as root (no password)."
echo
)
agetty --reload 2>/dev/null || true if [ "$new" != "$(cat "$issue" 2>/dev/null)" ]; then
printf '%s\n' "$new" > "$issue"
agetty --reload 2>/dev/null || true
fi

BIN
iso/overlay/splash.png Normal file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View file

@ -29,6 +29,22 @@ server {
try_files $uri $uri/ $uri.html =404; try_files $uri $uri/ $uri.html =404;
} }
# Poll counter (ops/poll/pollsvc.py) — cookie-free, rate-limited.
location /api/poll/ {
# Public traffic arrives from the edge proxy via 192.168.178.164.
# Only that hop is trusted when unwinding X-Forwarded-For, so a
# client-supplied X-Forwarded-For cannot forge a fresh "device".
set_real_ip_from 192.168.178.164;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
limit_req zone=poll burst=10 nodelay;
proxy_pass http://127.0.0.1:8090/;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header User-Agent $http_user_agent;
proxy_read_timeout 5s;
}
location = /favicon.svg { location = /favicon.svg {
access_log off; access_log off;
log_not_found off; log_not_found off;

View file

@ -0,0 +1,2 @@
# Rate limit for the poll API (http context; included from conf.d).
limit_req_zone $binary_remote_addr zone=poll:1m rate=30r/m;

View file

@ -16,6 +16,7 @@ install -d -o "$OWNER" -g "$OWNER" -m 0755 "$WEBROOT"
install -d -o "$OWNER" -g "$OWNER" -m 0755 "$SRCROOT" install -d -o "$OWNER" -g "$OWNER" -m 0755 "$SRCROOT"
cp "$(dirname "$0")/furtka.org.conf" "$SITE_CONF" cp "$(dirname "$0")/furtka.org.conf" "$SITE_CONF"
cp "$(dirname "$0")/poll-ratelimit.conf" /etc/nginx/conf.d/poll-ratelimit.conf
ln -sfn "$SITE_CONF" "$SITE_LINK" ln -sfn "$SITE_CONF" "$SITE_LINK"
# Drop the Ubuntu default site so it doesn't shadow us on :80. # Drop the Ubuntu default site so it doesn't shadow us on :80.

View file

@ -0,0 +1,17 @@
[Unit]
Description=furtka.org poll counter
After=network.target
[Service]
ExecStart=/usr/bin/python3 /opt/furtka-poll/pollsvc.py
DynamicUser=yes
StateDirectory=furtka-poll
Restart=on-failure
RestartSec=3
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
[Install]
WantedBy=multi-user.target

172
ops/poll/pollsvc.py Normal file
View file

@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""Tiny cookie-free poll counter for furtka.org.
Runs behind nginx on 127.0.0.1:8090. One SQLite file, no dependencies
beyond the standard library.
GET /<poll> -> {"poll": "logo", "counts": {"m": 3, "r": 5}, "total": 8}
POST /<poll> -> body {"choice": "m"}; answers like GET plus "yours"
A device is identified by a salted SHA-256 of client IP (as resolved by
nginx from the trusted edge proxy) + User-Agent, so nobody needs a cookie
and the raw address is never stored. Voting again
from the same device changes the vote instead of adding one.
"""
import hashlib
import json
import logging
import os
import sqlite3
import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
POLLS = {
"logo": ("m", "r"),
}
STATE_DIR = os.environ.get("STATE_DIRECTORY", "/var/lib/furtka-poll")
DB_PATH = os.path.join(STATE_DIR, "votes.db")
SALT_PATH = os.path.join(STATE_DIR, "salt")
LISTEN = os.environ.get("POLL_LISTEN", "127.0.0.1")
PORT = int(os.environ.get("POLL_PORT", "8090"))
MAX_BODY = 512
log = logging.getLogger("pollsvc")
_lock = threading.Lock()
def _salt() -> bytes:
try:
with open(SALT_PATH, "rb") as f:
s = f.read()
if len(s) >= 16:
return s
except FileNotFoundError:
pass
s = os.urandom(32)
with open(SALT_PATH, "wb") as f:
f.write(s)
os.chmod(SALT_PATH, 0o600)
return s
def _db() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
conn.execute(
"CREATE TABLE IF NOT EXISTS votes ("
" poll TEXT NOT NULL, device TEXT NOT NULL, choice TEXT NOT NULL,"
" ts INTEGER NOT NULL DEFAULT (unixepoch()),"
" PRIMARY KEY (poll, device))"
)
conn.commit()
return conn
SALT = None
CONN = None
def counts(poll: str) -> dict:
with _lock:
rows = CONN.execute(
"SELECT choice, COUNT(*) FROM votes WHERE poll=? GROUP BY choice", (poll,)
).fetchall()
c = {k: 0 for k in POLLS[poll]}
for choice, n in rows:
if choice in c:
c[choice] = n
return {"poll": poll, "counts": c, "total": sum(c.values())}
class Handler(BaseHTTPRequestHandler):
server_version = "furtka-poll/1"
def log_message(self, fmt, *args): # quiet; nginx has the access log
log.debug(fmt, *args)
def _client_ip(self) -> str:
# nginx resolves the trusted proxy chain (real_ip module, see
# ops/nginx/furtka.org.conf) and hands us the result as X-Real-IP.
# We never parse X-Forwarded-For here: its leading entries are
# whatever the client chose to send.
return self.headers.get("X-Real-IP") or self.client_address[0]
def _device(self) -> str:
ua = self.headers.get("User-Agent", "")
h = hashlib.sha256(SALT + self._client_ip().encode() + b"|" + ua.encode())
return h.hexdigest()[:32]
def _json(self, status: int, payload: dict):
body = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def _poll(self):
name = self.path.strip("/").split("?")[0]
if name not in POLLS:
self._json(404, {"error": "no such poll"})
return None
return name
def do_GET(self):
poll = self._poll()
if not poll:
return
payload = counts(poll)
with _lock:
row = CONN.execute(
"SELECT choice FROM votes WHERE poll=? AND device=?", (poll, self._device())
).fetchone()
payload["yours"] = row[0] if row else None
self._json(200, payload)
def do_POST(self):
poll = self._poll()
if not poll:
return
try:
n = int(self.headers.get("Content-Length", "0"))
if n > MAX_BODY:
raise ValueError("body too large")
data = json.loads(self.rfile.read(n) or b"{}")
choice = str(data.get("choice", ""))
except (ValueError, json.JSONDecodeError):
self._json(400, {"error": "bad request"})
return
if choice not in POLLS[poll]:
self._json(400, {"error": "unknown choice"})
return
with _lock:
CONN.execute(
"INSERT INTO votes (poll, device, choice) VALUES (?,?,?)"
" ON CONFLICT(poll, device) DO UPDATE SET choice=excluded.choice, ts=unixepoch()",
(poll, self._device(), choice),
)
CONN.commit()
payload = counts(poll)
payload["yours"] = choice
self._json(200, payload)
def main():
global SALT, CONN
logging.basicConfig(
level=os.environ.get("POLL_LOGLEVEL", "INFO"),
stream=sys.stderr,
format="%(levelname)s %(message)s",
)
os.makedirs(STATE_DIR, exist_ok=True)
SALT = _salt()
CONN = _db()
srv = ThreadingHTTPServer((LISTEN, PORT), Handler)
log.info("listening on %s:%d, db %s", LISTEN, PORT, DB_PATH)
srv.serve_forever()
if __name__ == "__main__":
main()

26
ops/poll/setup-poll.sh Executable file
View file

@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Install / update the poll counter on forge-runner-01. Idempotent.
#
# Usage (on the VM, with sudo):
# sudo ops/poll/setup-poll.sh # install / update
# sudo ops/poll/setup-poll.sh --reset # ... and wipe all votes
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
if [ "${1:-}" = "--reset" ]; then
systemctl stop furtka-poll 2>/dev/null || true
rm -f /var/lib/furtka-poll/votes.db
echo "votes wiped"
fi
install -d -m 0755 /opt/furtka-poll
install -m 0644 "$HERE/pollsvc.py" /opt/furtka-poll/pollsvc.py
install -m 0644 "$HERE/furtka-poll.service" /etc/systemd/system/furtka-poll.service
systemctl daemon-reload
systemctl enable --now furtka-poll
systemctl restart furtka-poll
sleep 1
systemctl --no-pager --lines=3 status furtka-poll
curl -fsS http://127.0.0.1:8090/logo && echo
echo "OK: furtka-poll running on 127.0.0.1:8090"

View file

@ -1,6 +1,6 @@
[project] [project]
name = "furtka" name = "furtka"
version = "26.18-alpha" version = "26.20-alpha"
description = "Open-source home server OS — simple enough for everyone." description = "Open-source home server OS — simple enough for everyone."
requires-python = ">=3.11" requires-python = ">=3.11"
readme = "README.md" readme = "README.md"

View file

@ -113,6 +113,15 @@ if [ -f "$ISO" ]; then
if ! upload_asset "$ISO"; then if ! upload_asset "$ISO"; then
echo "warning: ISO upload failed — release published without ISO asset" >&2 echo "warning: ISO upload failed — release published without ISO asset" >&2
fi fi
# Checksum sidecar for the download page on furtka.org (and anyone
# verifying by hand). Uploaded even when the ISO upload reported a
# failure — a 504 there often still lands the file. Written from inside
# dist/ so the line carries the bare filename `sha256sum -c` expects.
ISO_SHA="$ISO.sha256"
(cd "$DIST_DIR" && sha256sum "$(basename "$ISO")" > "$(basename "$ISO_SHA")")
if ! upload_asset "$ISO_SHA"; then
echo "warning: ISO checksum upload failed" >&2
fi
fi fi
echo "Release $VERSION published: https://$HOST/$REPO/releases/tag/$VERSION" echo "Release $VERSION published: https://$HOST/$REPO/releases/tag/$VERSION"

View file

@ -407,7 +407,7 @@ def _request(port, path, cookie=None, method="GET", body=None):
def test_http_get_apps_route(fake_dirs, no_docker, admin_session): def test_http_get_apps_route(fake_dirs, no_docker, admin_session):
"""Smoke test the actual HTTP server with a real socket, urllib client.""" """Smoke test the actual HTTP server with a real socket, urllib client."""
server = api.HTTPServer(("127.0.0.1", 0), api._Handler) # port 0 → ephemeral server = api.ThreadingHTTPServer(("127.0.0.1", 0), api._Handler) # port 0 → ephemeral
port = server.server_address[1] port = server.server_address[1]
t = threading.Thread(target=server.serve_forever, daemon=True) t = threading.Thread(target=server.serve_forever, daemon=True)
t.start() t.start()
@ -431,7 +431,7 @@ def test_http_get_apps_route(fake_dirs, no_docker, admin_session):
def test_http_post_install_unknown_app(fake_dirs, admin_session): def test_http_post_install_unknown_app(fake_dirs, admin_session):
server = api.HTTPServer(("127.0.0.1", 0), api._Handler) server = api.ThreadingHTTPServer(("127.0.0.1", 0), api._Handler)
port = server.server_address[1] port = server.server_address[1]
t = threading.Thread(target=server.serve_forever, daemon=True) t = threading.Thread(target=server.serve_forever, daemon=True)
t.start() t.start()
@ -459,7 +459,7 @@ def test_http_post_install_unknown_app(fake_dirs, admin_session):
def _start_server(): def _start_server():
server = api.HTTPServer(("127.0.0.1", 0), api._Handler) server = api.ThreadingHTTPServer(("127.0.0.1", 0), api._Handler)
port = server.server_address[1] port = server.server_address[1]
t = threading.Thread(target=server.serve_forever, daemon=True) t = threading.Thread(target=server.serve_forever, daemon=True)
t.start() t.start()
@ -980,7 +980,7 @@ def test_update_settings_unknown_app(fake_dirs):
def test_http_get_settings_route(fake_dirs, no_docker, admin_session): def test_http_get_settings_route(fake_dirs, no_docker, admin_session):
_, bundled = fake_dirs _, bundled = fake_dirs
_write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST) _write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST)
server = api.HTTPServer(("127.0.0.1", 0), api._Handler) server = api.ThreadingHTTPServer(("127.0.0.1", 0), api._Handler)
port = server.server_address[1] port = server.server_address[1]
t = threading.Thread(target=server.serve_forever, daemon=True) t = threading.Thread(target=server.serve_forever, daemon=True)
t.start() t.start()
@ -1211,7 +1211,7 @@ def test_http_post_update_route(
api._do_install("fileshare") api._do_install("fileshare")
update_docker_stubs["up_called"] = 0 # reset counter after install update_docker_stubs["up_called"] = 0 # reset counter after install
update_docker_stubs["local"] = {"samba": "sha256:NEW"} update_docker_stubs["local"] = {"samba": "sha256:NEW"}
server = api.HTTPServer(("127.0.0.1", 0), api._Handler) server = api.ThreadingHTTPServer(("127.0.0.1", 0), api._Handler)
port = server.server_address[1] port = server.server_address[1]
t = threading.Thread(target=server.serve_forever, daemon=True) t = threading.Thread(target=server.serve_forever, daemon=True)
t.start() t.start()
@ -1236,7 +1236,7 @@ def test_http_post_update_route(
def test_http_post_install_with_settings(fake_dirs, no_docker, no_systemd_run, admin_session): def test_http_post_install_with_settings(fake_dirs, no_docker, no_systemd_run, admin_session):
_, bundled = fake_dirs _, bundled = fake_dirs
_write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST) _write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST)
server = api.HTTPServer(("127.0.0.1", 0), api._Handler) server = api.ThreadingHTTPServer(("127.0.0.1", 0), api._Handler)
port = server.server_address[1] port = server.server_address[1]
t = threading.Thread(target=server.serve_forever, daemon=True) t = threading.Thread(target=server.serve_forever, daemon=True)
t.start() t.start()
@ -1746,3 +1746,103 @@ def test_set_internet_disable_when_not_published_is_a_noop(
status, body = api._do_set_internet("fileshare", {"enabled": False}) status, body = api._do_set_internet("fileshare", {"enabled": False})
assert status == 200 assert status == 200
assert body == {"enabled": False} assert body == {"enabled": False}
# --- hardening: names, body size, cross-site POSTs, crash handling ----------
@pytest.mark.parametrize("bad", ["/", ".", "..", "../catalog"])
def test_http_remove_rejects_path_names(fake_dirs, no_docker, admin_session, bad):
apps, _ = fake_dirs
(apps / "fileshare").mkdir()
server, port = _start_server()
try:
req = _request(
port, "/api/apps/remove", cookie=admin_session, method="POST", body={"name": bad}
)
with pytest.raises(urllib.error.HTTPError) as e:
urllib.request.urlopen(req)
assert e.value.code == 400
assert (apps / "fileshare").exists()
finally:
server.shutdown()
server.server_close()
def test_http_post_rejects_oversized_body(fake_dirs, admin_session):
server, port = _start_server()
try:
import http.client
# Announce a huge body but never send it: the server must answer
# from the header alone instead of waiting to buffer it.
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=5)
conn.putrequest("POST", "/api/apps/remove")
conn.putheader("Cookie", admin_session)
conn.putheader("Content-Length", str(api.MAX_BODY_BYTES + 1))
conn.endheaders()
assert conn.getresponse().status == 413
conn.close()
finally:
server.shutdown()
server.server_close()
def test_http_post_rejects_same_site_other_origin(fake_dirs, no_docker, admin_session):
apps, _ = fake_dirs
(apps / "fileshare").mkdir()
server, port = _start_server()
try:
req = _request(
port,
"/api/apps/remove",
cookie=admin_session,
method="POST",
body={"name": "fileshare"},
)
req.add_header("Sec-Fetch-Site", "same-site")
with pytest.raises(urllib.error.HTTPError) as e:
urllib.request.urlopen(req)
assert e.value.code == 403
assert (apps / "fileshare").exists()
finally:
server.shutdown()
server.server_close()
def test_http_unexpected_exception_returns_json_500(fake_dirs, admin_session, monkeypatch):
def boom():
raise FileNotFoundError("docker")
monkeypatch.setattr(api, "_list_installed", boom)
server, port = _start_server()
try:
with pytest.raises(urllib.error.HTTPError) as e:
urllib.request.urlopen(_request(port, "/api/apps", cookie=admin_session))
assert e.value.code == 500
assert "internal error" in json.loads(e.value.read())["error"]
finally:
server.shutdown()
server.server_close()
def test_login_with_corrupt_users_file_is_503_not_setup(fake_dirs, tmp_path):
(tmp_path / "users.json").write_text("")
server, port = _start_server()
try:
with pytest.raises(urllib.error.HTTPError) as e:
urllib.request.urlopen(_request(port, "/login"))
assert e.value.code == 503
req = _request(
port,
"/login",
method="POST",
body={"username": "mallory", "password": "longenough1", "password2": "longenough1"},
)
with pytest.raises(urllib.error.HTTPError) as e:
urllib.request.urlopen(req)
assert e.value.code == 503
assert (tmp_path / "users.json").read_text() == ""
finally:
server.shutdown()
server.server_close()

View file

@ -195,3 +195,198 @@ def test_build_archinstall_creds_uses_archinstall_sentinel_keys():
"groups": [], "groups": [],
} }
] ]
def test_log_page_done_state_shows_usb_step_before_restart(monkeypatch, tmp_path):
"""The 'pull the USB stick' step must be visible *before* the restart
button — after the click the box reboots within seconds (first hardware
bench booted the stick again)."""
import app as app_module
log_path = tmp_path / "install.log"
log_path.write_text("Installation completed without any errors.\n")
monkeypatch.setattr(app_module, "INSTALL_LOG", log_path)
app_module.settings["hostname"] = "bench"
html = app_module.app.test_client().get("/install/log").get_data(as_text=True)
assert '<ol class="next-steps">' in html
assert "http://bench.local" in html
assert "USB stick is out" in html
assert html.index("next-steps") < html.index("USB stick is out")
def test_log_page_running_state_has_no_restart_button(monkeypatch, tmp_path):
import app as app_module
log_path = tmp_path / "install.log"
log_path.write_text("Creating partitions: /dev/sda\n")
monkeypatch.setattr(app_module, "INSTALL_LOG", log_path)
html = app_module.app.test_client().get("/install/log").get_data(as_text=True)
assert "next-steps" not in html
assert "install_reboot" not in html and "restart now" not in html
def test_nonzero_exit_without_traceback_is_an_error():
import app as app_module
# OOM kill / signal: archinstall dies mid-pacstrap and prints nothing.
log = "Installing packages: ['base'\n"
assert app_module.parse_install_progress(log)["status"] == "running"
assert app_module.parse_install_progress(log, exit_code=-9)["status"] == "error"
assert app_module.parse_install_progress(log, exit_code=0)["status"] == "running"
class _FakeProc:
def __init__(self, code=None):
self.code = code
def poll(self):
return self.code
def _install_env(monkeypatch, tmp_path):
import app as app_module
monkeypatch.setattr(app_module, "STATE_DIR", tmp_path)
monkeypatch.setattr(app_module, "INSTALL_LOG", tmp_path / "install.log")
monkeypatch.setattr(app_module, "_install_proc", None)
monkeypatch.delenv("FURTKA_DRY_RUN", raising=False)
monkeypatch.setitem(app_module.settings, "boot_drive", "/dev/sda")
monkeypatch.setitem(app_module.settings, "username", "u")
monkeypatch.setitem(app_module.settings, "password", "pw12345678")
monkeypatch.setattr(app_module, "release_disk", lambda d: [f"released {d}"])
monkeypatch.setattr(app_module, "build_disk_config", lambda d: {"device": d})
return app_module
def test_install_run_refuses_second_concurrent_install(monkeypatch, tmp_path):
app_module = _install_env(monkeypatch, tmp_path)
spawned = []
monkeypatch.setattr(
app_module, "spawn_archinstall", lambda *a: spawned.append(a) or _FakeProc()
)
client = app_module.app.test_client()
assert client.post("/install/run").status_code == 302
assert client.post("/install/run").status_code == 302
assert len(spawned) == 1
def test_install_run_disk_probe_failure_lands_in_log(monkeypatch, tmp_path):
app_module = _install_env(monkeypatch, tmp_path)
def boom(_):
raise RuntimeError("parted could not read /dev/sda")
monkeypatch.setattr(app_module, "build_disk_config", boom)
resp = app_module.app.test_client().post("/install/run")
assert resp.status_code == 302
log = (tmp_path / "install.log").read_text()
assert "Traceback (most recent call last)" in log
assert app_module.parse_install_progress(log)["status"] == "error"
def test_install_log_json_reports_crashed_archinstall(monkeypatch, tmp_path):
app_module = _install_env(monkeypatch, tmp_path)
(tmp_path / "install.log").write_text("Installing packages: ['base'\n")
monkeypatch.setattr(app_module, "_install_proc", _FakeProc(code=137))
data = app_module.app.test_client().get("/install/log.json").get_json()
assert data["progress"]["status"] == "error"
assert "exit code 137" in data["log"]
def test_step2_rejects_disk_not_offered(monkeypatch):
import app as app_module
offered = [
{
"name": "/dev/sda",
"usable": True,
"size": "500 GB",
"type_label": "SSD",
"health_label": "Healthy",
"score": 27,
"reason": "",
"warning": "",
},
{
"name": "/dev/sdb",
"usable": False,
"size": "8 GB",
"type_label": "USB",
"health_label": "Status unknown",
"score": 10,
"reason": "Too small",
"warning": "",
},
]
monkeypatch.setattr(app_module, "list_scored_devices", lambda: offered)
monkeypatch.setitem(app_module.settings, "boot_drive", "")
client = app_module.app.test_client()
for bad in ("/dev/sdb", "/dev/nope", "/etc/passwd"):
resp = client.post("/install/step2", data={"boot_drive": bad})
assert resp.status_code == 200
assert app_module.settings["boot_drive"] == ""
resp = client.post("/install/step2", data={"boot_drive": "/dev/sda"})
assert resp.status_code == 302
assert app_module.settings["boot_drive"] == "/dev/sda"
def test_validate_step1_lowercases_names():
errors, values = validate_step1(
{
"hostname": "Furtka",
"username": "Daniel",
"password": "topsecretpw",
"password2": "topsecretpw",
"language": "de",
}
)
assert errors == []
assert values["hostname"] == "furtka"
assert values["username"] == "daniel"
def test_validate_step1_rejects_reserved_usernames():
for name in ("root", "caddy", "docker", "nobody"):
errors, _ = validate_step1(
{
"hostname": "furtka",
"username": name,
"password": "topsecretpw",
"password2": "topsecretpw",
"language": "en",
}
)
assert len(errors) == 1 and "reserved" in errors[0], name
def test_post_install_sets_wait_online_any(monkeypatch):
import base64
import app as app_module
cmds = app_module._post_install_commands("h", "u", "pw12345678")
hits = [c for c in cmds if "systemd-networkd-wait-online.service.d" in c]
assert len(hits) == 1
payload = hits[0].split("printf %s ")[1].split(" |")[0]
assert "--any" in base64.b64decode(payload).decode()
def test_wizard_pages_redirect_to_progress_while_installing(monkeypatch, tmp_path):
app_module = _install_env(monkeypatch, tmp_path)
monkeypatch.setattr(app_module, "_install_proc", _FakeProc(code=None))
client = app_module.app.test_client()
for path in ("/install/step1", "/install/step2", "/install/overview"):
resp = client.get(path)
assert resp.status_code == 302
assert resp.headers["Location"].endswith("/install/log")
def test_error_page_offers_retry(monkeypatch, tmp_path):
app_module = _install_env(monkeypatch, tmp_path)
(tmp_path / "install.log").write_text("Traceback (most recent call last)\nboom\n")
html = app_module.app.test_client().get("/install/log").get_data(as_text=True)
assert "Try again" in html
assert "next-steps" not in html

View file

@ -40,14 +40,15 @@ def test_load_users_returns_empty_when_missing(tmp_users_file):
assert auth.load_users() == {} assert auth.load_users() == {}
def test_load_users_returns_empty_on_junk(tmp_users_file): @pytest.mark.parametrize("content", ["{not json", "[]", "", "\x00\x00"])
tmp_users_file.write_text("{not json") def test_load_users_fails_closed_on_corrupt_file(tmp_users_file, content):
assert auth.load_users() == {} """A damaged users.json must not read as "no admin yet" — that would
turn /login into an open create-admin form for anyone on the LAN."""
tmp_users_file.write_text(content)
def test_load_users_returns_empty_on_non_dict(tmp_users_file): with pytest.raises(auth.UsersFileError):
tmp_users_file.write_text("[]") auth.load_users()
assert auth.load_users() == {} with pytest.raises(auth.UsersFileError):
auth.setup_needed()
def test_save_users_atomic_and_0600(tmp_users_file): def test_save_users_atomic_and_0600(tmp_users_file):

View file

@ -405,3 +405,48 @@ def test_write_and_read_state_round_trip(catalog):
assert s["stage"] == "downloading" assert s["stage"] == "downloading"
assert s["latest"] == "26.6" assert s["latest"] == "26.6"
assert "updated_at" in s assert "updated_at" in s
def test_sync_catalog_failure_writes_error_state(catalog, monkeypatch):
"""A boot-time network failure must not leave the state file on
'checking' forever — the UI would show 'checking…' with nothing running."""
from furtka import _release_common as _rc
def offline(host, repo, path, *, error_cls=RuntimeError):
raise error_cls("forgejo api https://x/releases: Network is unreachable")
monkeypatch.setattr(_rc, "forgejo_api", offline)
with pytest.raises(catalog.CatalogError, match="Network is unreachable"):
catalog.sync_catalog()
state = catalog.read_state()
assert state["stage"] == "error"
assert "Network is unreachable" in state["error"]
def test_sync_catalog_unexpected_error_lands_as_error_stage(catalog, monkeypatch):
"""Disk full / mid-body disconnect used to leave the stage at
'downloading' forever because only CatalogError was caught."""
_stub_forgejo_release(monkeypatch, catalog, tag="26.6")
def boom(*a, **kw):
raise OSError(28, "No space left on device")
monkeypatch.setattr(catalog._rc, "download", boom)
with pytest.raises(catalog.CatalogError, match="No space left"):
catalog.sync_catalog()
assert catalog.read_state()["stage"] == "error"
def test_release_download_wraps_socket_errors(tmp_path, monkeypatch):
from furtka import _release_common as rc
def reset(*a, **kw):
raise ConnectionResetError("reset by peer")
monkeypatch.setattr(rc.urllib.request, "urlopen", reset)
with pytest.raises(rc.ReleaseError, match="reset by peer"):
rc.download("https://x/y", tmp_path / "y")
with pytest.raises(rc.ReleaseError, match="reset by peer"):
rc.forgejo_api("x", "a/b", "/releases")

View file

@ -188,7 +188,7 @@ def test_app_install_uses_plan_for_named_install(tmp_path, monkeypatch, capsys):
_write_manifest(bundled, "mosquitto") _write_manifest(bundled, "mosquitto")
_write_manifest(bundled, "zigbee2mqtt", requires=[{"app": "mosquitto"}]) _write_manifest(bundled, "zigbee2mqtt", requires=[{"app": "mosquitto"}])
from furtka import installer, reconciler from furtka import install_runner, installer
# Stub install_from so we don't actually copy files / mess with placeholders. # Stub install_from so we don't actually copy files / mess with placeholders.
install_calls: list[str] = [] install_calls: list[str] = []
@ -198,12 +198,22 @@ def test_app_install_uses_plan_for_named_install(tmp_path, monkeypatch, capsys):
return tmp_path / src.name return tmp_path / src.name
monkeypatch.setattr(installer, "install_from", fake_install_from) monkeypatch.setattr(installer, "install_from", fake_install_from)
monkeypatch.setattr(reconciler, "reconcile", lambda *a, **k: []) monkeypatch.setattr(install_runner, "_INSTALL_PLAN", tmp_path / "install-plan.json")
# Named installs drive the docker phase through run_install (so on_install
# hooks fire) — stub it and assert it's invoked with the chosen target.
run_calls: list[str] = []
monkeypatch.setattr(install_runner, "run_install", lambda name: run_calls.append(name))
rc = main(["app", "install", "zigbee2mqtt"]) rc = main(["app", "install", "zigbee2mqtt"])
assert rc == 0 assert rc == 0
# Provider installed before consumer. # Provider installed before consumer.
assert install_calls == ["mosquitto", "zigbee2mqtt"] assert install_calls == ["mosquitto", "zigbee2mqtt"]
# The docker phase (which fires on_install hooks) ran for the target.
assert run_calls == ["zigbee2mqtt"]
# And the plan file was staged for run_install to consume.
plan = json.loads(install_runner.plan_path().read_text())
assert plan["target"] == "zigbee2mqtt"
assert plan["to_install"] == ["mosquitto", "zigbee2mqtt"]
def test_app_install_named_with_cycle_exits_2(tmp_path, monkeypatch, capsys): def test_app_install_named_with_cycle_exits_2(tmp_path, monkeypatch, capsys):

146
tests/test_diskprep.py Normal file
View file

@ -0,0 +1,146 @@
"""release_disk() against a recorded `lsblk -P` picture of the first
hardware bench: an SSD with an old Proxmox VE `pve` volume group stacked on
sda3 (swap, root, thin pool). Commands are captured through the injected
runner — nothing touches real devices."""
import subprocess
from pathlib import Path
import pytest
from diskprep import release_disk
# What the bench looked like before cleanup (lsblk -lnP -o NAME,TYPE,MOUNTPOINTS /dev/sda).
BENCH_BEFORE = """NAME="sda" TYPE="disk" MOUNTPOINTS=""
NAME="sda1" TYPE="part" MOUNTPOINTS=""
NAME="sda3" TYPE="part" MOUNTPOINTS=""
NAME="pve-swap" TYPE="lvm" MOUNTPOINTS="[SWAP]"
NAME="pve-root" TYPE="lvm" MOUNTPOINTS="/mnt/old"
NAME="pve-data_tmeta" TYPE="lvm" MOUNTPOINTS=""
NAME="pve-data-tpool" TYPE="lvm" MOUNTPOINTS=""
NAME="pve-data" TYPE="lvm" MOUNTPOINTS=""
NAME="pve-data_tdata" TYPE="lvm" MOUNTPOINTS=""
NAME="pve-data-tpool" TYPE="lvm" MOUNTPOINTS=""
NAME="pve-data" TYPE="lvm" MOUNTPOINTS=""
"""
BENCH_CLEAN = """NAME="sda" TYPE="disk" MOUNTPOINTS=""
NAME="sda1" TYPE="part" MOUNTPOINTS=""
NAME="sda3" TYPE="part" MOUNTPOINTS=""
"""
EMPTY = """NAME="sda" TYPE="disk" MOUNTPOINTS=""
"""
class FakeRunner:
"""Answers lsblk from a script of snapshots (one per call, last one
sticks) and records everything else."""
def __init__(self, lsblk_outputs):
self.lsblk_outputs = list(lsblk_outputs)
self.calls = []
def __call__(self, cmd, capture_output=True, text=True):
if cmd[0] == "lsblk":
if len(self.lsblk_outputs) > 1:
out = self.lsblk_outputs.pop(0)
else:
out = self.lsblk_outputs[0]
return subprocess.CompletedProcess(cmd, 0, out, "")
self.calls.append(cmd)
return subprocess.CompletedProcess(cmd, 0, "", "")
def test_release_tears_down_lvm_stack_leaves_first():
# lsblk #1 (inventory) and #2 (first removal pass) see the full stack;
# everything after that is clean — the dm removals "worked".
runner = FakeRunner([BENCH_BEFORE, BENCH_BEFORE, BENCH_CLEAN])
lines = release_disk("/dev/sda", run=runner)
calls = runner.calls
assert ["swapoff", "/dev/pve-swap"] in calls
assert ["umount", "--all-targets", "/dev/pve-root"] in calls
removed = [c[2] for c in calls if c[:2] == ["dmsetup", "remove"]]
# Thin LV before its pool, pool before tmeta/tdata.
assert removed.index("pve-data") < removed.index("pve-data-tpool")
assert removed.index("pve-data-tpool") < removed.index("pve-data_tmeta")
assert set(removed) == {
"pve-swap",
"pve-root",
"pve-data_tmeta",
"pve-data-tpool",
"pve-data",
"pve-data_tdata",
}
assert ["wipefs", "-a", "/dev/sda3"] in calls
assert ["wipefs", "-a", "/dev/sda1"] in calls
assert ["wipefs", "-a", "/dev/sda"] in calls
assert calls[-1] == ["blockdev", "--rereadpt", "/dev/sda"]
assert not any("WARNING" in line for line in lines)
assert lines[0].startswith("Releasing /dev/sda")
def test_release_warns_when_stack_survives():
runner = FakeRunner([BENCH_BEFORE]) # never becomes clean
lines = release_disk("/dev/sda", run=runner)
assert any("WARNING: still in use" in line for line in lines)
# Still tries to give archinstall a chance.
assert ["blockdev", "--rereadpt", "/dev/sda"] in runner.calls
def test_release_empty_disk_only_wipes_and_rereads():
runner = FakeRunner([EMPTY])
lines = release_disk("sda", run=runner) # bare name is accepted too
assert runner.calls == [["wipefs", "-a", "/dev/sda"], ["blockdev", "--rereadpt", "/dev/sda"]]
assert any("nothing stacked" in line for line in lines)
def test_release_survives_missing_tools():
def runner(cmd, capture_output=True, text=True):
if cmd[0] == "lsblk":
return subprocess.CompletedProcess(cmd, 0, BENCH_BEFORE, "")
raise FileNotFoundError(cmd[0])
lines = release_disk("/dev/sda", run=runner)
assert any("command not found" in line for line in lines)
@pytest.fixture
def app_module(monkeypatch, tmp_path):
import app as app_module
monkeypatch.setattr(app_module, "INSTALL_LOG", tmp_path / "install.log")
monkeypatch.setattr(app_module, "STATE_DIR", tmp_path / "state")
return app_module
def test_install_run_releases_disk_before_spawning(app_module, monkeypatch):
order = []
def fake_release(disk):
order.append(("release", disk))
return ["Releasing X"]
monkeypatch.setattr(app_module, "release_disk", fake_release)
monkeypatch.setattr(app_module, "spawn_archinstall", lambda *a: order.append(("spawn",)))
monkeypatch.setattr(app_module, "build_archinstall_config", lambda s: {})
monkeypatch.setattr(app_module, "build_archinstall_creds", lambda s: {})
monkeypatch.delenv("FURTKA_DRY_RUN", raising=False)
app_module.settings.update(boot_drive="/dev/sda", username="u", password="pw12345678")
client = app_module.app.test_client()
resp = client.post("/install/run")
assert resp.status_code == 302
assert order == [("release", "/dev/sda"), ("spawn",)]
assert Path(app_module.INSTALL_LOG).read_text().startswith("Releasing X")
def test_progress_names_disk_in_use_failure(app_module):
log = (
"Traceback (most recent call last):\n"
"_ped.IOException: ... unable to inform the kernel of the change ..."
)
progress = app_module.parse_install_progress(log)
assert progress["status"] == "error"
assert "still in use" in progress["phase"]

View file

@ -1,47 +1,63 @@
import json
import drives
import pytest
from drives import ( from drives import (
get_drive_type_label, MIN_DISK_GB,
_boot_device_from_cmdline,
classify_drive,
format_size,
get_drive_type_score, get_drive_type_score,
get_size_score, get_size_score,
parse_lsblk_output, parse_lsblk_json,
parse_size_gb,
score_device,
) )
GB = 1024**3
def test_parse_size_gb_terabytes():
assert parse_size_gb("1T") == 1024.0
def test_parse_size_gb_gigabytes(): def _lsblk(*entries):
assert parse_size_gb("500G") == 500.0 """Build lsblk -J output. Each entry: (name, size_gb, type, extra-dict)."""
devs = []
for name, size_gb, dev_type, *extra in entries:
d = {
"name": name,
"size": int(size_gb * GB),
"type": dev_type,
"ro": False,
"rm": False,
"tran": "sata",
"rota": True,
}
if extra:
d.update(extra[0])
devs.append(d)
return json.dumps({"blockdevices": devs})
def test_parse_size_gb_megabytes(): @pytest.fixture(autouse=True)
assert parse_size_gb("2048M") == 2.0 def _smart_passed(monkeypatch):
monkeypatch.setattr(drives, "_smart_status", lambda _: "passed")
def test_parse_size_gb_european_comma_decimal(): def _names(devices):
assert parse_size_gb("1,5T") == 1.5 * 1024 return [d["name"] for d in devices]
def test_parse_size_gb_empty_returns_none(): def test_classify_uses_transport_and_rotation():
assert parse_size_gb("") is None assert classify_drive("nvme0n1", tran="nvme", rota=False) == "NVMe"
assert classify_drive("sda", tran="sata", rota=False) == "SSD"
assert classify_drive("sda", tran="sata", rota=True) == "HDD"
assert classify_drive("mmcblk0", tran="", rota=False) == "eMMC"
assert classify_drive("sdb", tran="usb", rota=False) == "USB"
assert classify_drive("sdc", tran="", rota=True, removable=True) == "USB"
# SD cards in a built-in reader are removable mmc — still eMMC/mmc class.
assert classify_drive("mmcblk1", tran="", rota=False, removable=True) == "eMMC"
def test_parse_size_gb_unknown_unit_returns_none(): def test_type_scores_rank_internal_over_usb():
assert parse_size_gb("500K") is None assert get_drive_type_score("NVMe") > get_drive_type_score("SSD")
assert get_drive_type_score("SSD") > get_drive_type_score("HDD")
assert get_drive_type_score("HDD") > get_drive_type_score("USB")
def test_drive_type_score_nvme():
assert get_drive_type_score("/dev/nvme0n1") == 15
def test_drive_type_score_ssd():
assert get_drive_type_score("/dev/ssd0") == 10
def test_drive_type_score_hdd_fallback():
assert get_drive_type_score("/dev/sda") == 5
def test_size_score_bands(): def test_size_score_bands():
@ -51,67 +67,145 @@ def test_size_score_bands():
assert get_size_score(1024) == 10 assert get_size_score(1024) == 10
def test_score_device_sums_type_and_size(monkeypatch): def test_format_size():
import drives assert format_size(500 * GB) == "500 GB"
assert format_size(1024 * GB) == "1 TB"
monkeypatch.setattr(drives, "get_drive_health", lambda _: 10) assert format_size(int(1.5 * 1024 * GB)) == "1.5 TB"
assert score_device("/dev/nvme0n1", 1024) == 15 + 10 + 10 assert format_size(int(7.5 * GB)) == "7.5 GB"
assert score_device("/dev/sda", 64) == 5 + 10 + 5
def test_parse_lsblk_drops_loop_and_rom(monkeypatch): def test_drops_loop_rom_zram_and_emmc_boot_areas():
import drives out = _lsblk(
("loop0", 2.5, "loop"),
monkeypatch.setattr(drives, "_smart_status", lambda _: "passed") ("sr0", 1, "rom"),
output = "loop0 2.5G loop\nsr0 1024M rom\nsda 500G disk\nnvme0n1 1T disk\n" ("zram0", 4, "disk"),
devices = parse_lsblk_output(output) ("mmcblk0boot0", 0.004, "disk"),
names = [d["name"] for d in devices] ("mmcblk0boot1", 0.004, "disk"),
assert names == ["/dev/nvme0n1", "/dev/sda"] ("mmcblk0rpmb", 0.004, "disk"),
("mmcblk0", 64, "disk", {"tran": "", "rota": False}),
)
assert _names(parse_lsblk_json(out)) == ["/dev/mmcblk0"]
def test_parse_lsblk_attaches_human_labels(monkeypatch): def test_drops_empty_card_reader_slots_and_read_only_disks():
import drives out = _lsblk(
("sdc", 0, "disk", {"rm": True, "tran": "usb"}),
monkeypatch.setattr(drives, "_smart_status", lambda _: "passed") ("sdd", 500, "disk", {"ro": True}),
output = "nvme0n1 1T disk\n" ("sda", 500, "disk"),
[dev] = parse_lsblk_output(output) )
assert dev["type_label"] == "NVMe" assert _names(parse_lsblk_json(out)) == ["/dev/sda"]
assert dev["health_label"] == "Healthy"
def test_parse_lsblk_surfaces_smart_warning(monkeypatch): def test_ssd_beats_bigger_hdd():
import drives # Regression: sdX SSDs used to be labelled HDD, so the 2 TB spinning disk
# won the recommendation over the 256 GB SSD.
out = _lsblk(
("sda", 2048, "disk", {"rota": True}),
("sdb", 256, "disk", {"rota": False}),
)
devices = parse_lsblk_json(out)
assert _names(devices) == ["/dev/sdb", "/dev/sda"]
assert [d["type_label"] for d in devices] == ["SSD", "HDD"]
def test_usb_disk_ranked_after_internal():
out = _lsblk(
("sda", 32, "disk", {"tran": "usb", "rota": False, "rm": True}),
("mmcblk0", 32, "disk", {"tran": "", "rota": False}),
)
devices = parse_lsblk_json(out)
assert _names(devices) == ["/dev/mmcblk0", "/dev/sda"]
assert devices[1]["warning"]
def test_too_small_disk_listed_but_unusable_and_last():
out = _lsblk(
("sda", MIN_DISK_GB - 1, "disk", {"rota": False}),
("sdb", 500, "disk"),
)
devices = parse_lsblk_json(out)
assert _names(devices) == ["/dev/sdb", "/dev/sda"]
assert devices[0]["usable"] is True
assert devices[1]["usable"] is False
assert str(MIN_DISK_GB) in devices[1]["reason"]
def test_bios_warns_about_disks_over_2tb():
out = _lsblk(("sda", 4096, "disk"))
[bios] = parse_lsblk_json(out, uefi=False)
[efi] = parse_lsblk_json(out, uefi=True)
assert "2 TB" in bios["warning"]
assert efi["warning"] == ""
def test_surfaces_smart_warning(monkeypatch):
monkeypatch.setattr(drives, "_smart_status", lambda _: "failed") monkeypatch.setattr(drives, "_smart_status", lambda _: "failed")
[dev] = parse_lsblk_output("sda 500G disk\n") [dev] = parse_lsblk_json(_lsblk(("sda", 500, "disk")))
assert dev["health_label"] == "SMART warning" assert dev["health_label"] == "SMART warning"
def test_drive_type_label_nvme_ssd_hdd(): def test_accepts_old_util_linux_string_flags():
assert get_drive_type_label("/dev/nvme0n1") == "NVMe" out = json.dumps(
assert get_drive_type_label("/dev/ssd0") == "SSD" {
assert get_drive_type_label("/dev/sda") == "HDD" "blockdevices": [
{
"name": "sda",
"size": str(500 * GB),
"type": "disk",
"ro": "0",
"rm": "0",
"tran": "sata",
"rota": "0",
},
]
}
)
[dev] = parse_lsblk_json(out)
assert dev["type_label"] == "SSD"
def test_parse_lsblk_handles_empty_output(): def test_handles_empty_and_garbage_output():
assert parse_lsblk_output("") == [] assert parse_lsblk_json("") == []
assert parse_lsblk_json("not json") == []
def test_parse_lsblk_drops_boot_usb(monkeypatch): def test_drops_boot_usb():
import drives out = _lsblk(
("sda", 500, "disk"),
monkeypatch.setattr(drives, "_smart_status", lambda _: "passed") ("sdb", 16, "disk", {"tran": "usb"}),
output = "sda 500G disk\nsdb 16G disk\nnvme0n1 1T disk\n" ("nvme0n1", 1024, "disk", {"tran": "nvme", "rota": False}),
devices = parse_lsblk_output(output, boot_disk="sdb") )
names = [d["name"] for d in devices] assert _names(parse_lsblk_json(out, boot_disk="sdb")) == ["/dev/nvme0n1", "/dev/sda"]
assert "/dev/sdb" not in names
assert names == ["/dev/nvme0n1", "/dev/sda"]
def test_parse_lsblk_no_boot_disk_keeps_all(monkeypatch): def test_boot_device_from_cmdline(monkeypatch):
import drives calls = []
monkeypatch.setattr(drives, "_smart_status", lambda _: "passed") class R:
output = "sda 500G disk\nsdb 16G disk\n" returncode = 0
names = [d["name"] for d in parse_lsblk_output(output, boot_disk=None)] stdout = "/dev/sdb1\n"
assert set(names) == {"/dev/sda", "/dev/sdb"}
monkeypatch.setattr(drives, "_run", lambda cmd, **_: calls.append(cmd) or R())
cmdline = "BOOT_IMAGE=/vmlinuz archisosearchuuid=2026-08-25-10-00-00-00 quiet"
assert _boot_device_from_cmdline(cmdline) == "/dev/sdb1"
assert calls[-1] == ["blkid", "-U", "2026-08-25-10-00-00-00"]
assert _boot_device_from_cmdline("archisolabel=FURTKA_2620") == "/dev/sdb1"
assert calls[-1] == ["blkid", "-L", "FURTKA_2620"]
assert _boot_device_from_cmdline("archisodevice=/dev/sr0") == "/dev/sr0"
assert _boot_device_from_cmdline("quiet splash") is None
def test_boot_disk_whole_disk_iso(monkeypatch):
# dd-written stick: ISO filesystem on the whole disk, PKNAME empty.
class R:
def __init__(self, out):
self.returncode = 0
self.stdout = out
def fake_run(cmd, **_):
if cmd[0] == "findmnt":
return R("/dev/sdb\n")
return R("\n")
monkeypatch.setattr(drives, "_run", fake_run)
assert drives._boot_disk_name() == "sdb"

View file

@ -478,3 +478,35 @@ def test_parse_hook_output_furtka_json_rejects_bad_payload(runner):
def test_parse_hook_output_furtka_json_invalid_json(runner): def test_parse_hook_output_furtka_json_invalid_json(runner):
with pytest.raises(runner.InstallRunnerError, match="invalid FURTKA_JSON"): with pytest.raises(runner.InstallRunnerError, match="invalid FURTKA_JSON"):
runner._parse_hook_output("FURTKA_JSON: {not json}\n") runner._parse_hook_output("FURTKA_JSON: {not json}\n")
def test_clear_stale_state_resets_abandoned_install(runner):
import time
runner.write_state("pulling_image", app="fileshare")
# Fresh state: could still be the runner starting up — leave it alone.
assert runner.clear_stale_state() is False
assert runner.read_state()["stage"] == "pulling_image"
# Two minutes later with nobody holding the lock: interrupted.
assert runner.clear_stale_state(now=time.time() + 120) is True
state = runner.read_state()
assert state["stage"] == "error"
assert state["app"] == "fileshare"
assert "interrupted" in state["error"]
def test_clear_stale_state_keeps_running_install(runner):
import time
runner.write_state("pulling_image", app="fileshare")
fh = runner.acquire_lock()
try:
assert runner.clear_stale_state(now=time.time() + 3600) is False
assert runner.read_state()["stage"] == "pulling_image"
finally:
fh.close()
def test_read_state_tolerates_non_object(runner):
runner.state_path().write_text("[1, 2]")
assert runner.read_state() == {}

View file

@ -73,6 +73,31 @@ def test_install_from_copies_files(tmp_path, fake_dirs):
assert (target / ".env").read_text() == "A=1" assert (target / ".env").read_text() == "A=1"
def test_install_from_copies_subdirectories(tmp_path, fake_dirs):
# Dependency hooks live under scripts/ — the whole subtree must be copied,
# not just top-level files, or providers ship without their hooks.
src = _write_app_source(tmp_path, "mosquitto", VALID_MANIFEST, env_example="A=1")
scripts = src / "scripts"
scripts.mkdir()
(scripts / "provision-client.sh").write_text("#!/bin/sh\necho hi\n")
target = installer.install_from(src)
assert (target / "scripts" / "provision-client.sh").read_text() == "#!/bin/sh\necho hi\n"
def test_install_from_reinstall_drops_stale_subdir_files(tmp_path, fake_dirs):
# A reinstall whose source renamed/removed a hook must not leave the old one.
src = _write_app_source(tmp_path, "mosquitto", VALID_MANIFEST, env_example="A=1")
scripts = src / "scripts"
scripts.mkdir()
(scripts / "old-hook.sh").write_text("old\n")
installer.install_from(src)
(scripts / "old-hook.sh").unlink()
(scripts / "new-hook.sh").write_text("new\n")
target = installer.install_from(src)
assert not (target / "scripts" / "old-hook.sh").exists()
assert (target / "scripts" / "new-hook.sh").read_text() == "new\n"
def test_install_from_preserves_existing_env(tmp_path, fake_dirs): def test_install_from_preserves_existing_env(tmp_path, fake_dirs):
src = _write_app_source(tmp_path, "fileshare", VALID_MANIFEST, env_example="A=new") src = _write_app_source(tmp_path, "fileshare", VALID_MANIFEST, env_example="A=new")
target = apps_dir() / "fileshare" target = apps_dir() / "fileshare"
@ -437,3 +462,58 @@ def test_install_plan_skips_already_installed(tmp_path, fake_dirs, monkeypatch):
) )
installer.install_plan(plan) installer.install_plan(plan)
assert calls == ["a"] assert calls == ["a"]
# --- hardening: app names, validate-before-write, .env quoting --------------
@pytest.mark.parametrize("bad", ["/", ".", "..", "../catalog", "a/b", "", "UPPER"])
def test_remove_refuses_non_app_names(fake_dirs, bad):
apps, _ = fake_dirs
(apps / "fileshare").mkdir()
with pytest.raises(installer.InstallError, match="not a valid app name"):
installer.remove(bad)
assert (apps / "fileshare").exists()
assert apps.exists()
def test_update_env_refuses_non_app_names(fake_dirs):
with pytest.raises(installer.InstallError, match="not a valid app name"):
installer.update_env("..", {"X": "1"})
def test_rejected_path_setting_never_reaches_env(tmp_path, fake_dirs):
media = tmp_path / "media"
media.mkdir()
src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST)
installer.install_from(src, settings={"MEDIA_PATH": str(media)})
env = apps_dir() / "jellyfin" / ".env"
with pytest.raises(installer.InstallError, match="system path"):
installer.update_env("jellyfin", {"MEDIA_PATH": "/etc"})
assert "/etc" not in env.read_text()
assert f"MEDIA_PATH={media}" in env.read_text()
def test_newline_in_setting_is_rejected(tmp_path, fake_dirs):
m = dict(VALID_MANIFEST, settings=[{"name": "PW", "label": "PW", "type": "password"}])
src = _write_app_source(tmp_path, "fileshare", m)
with pytest.raises(installer.InstallError, match="line breaks"):
installer.install_from(src, settings={"PW": "abc\nCOMPOSE_PROJECT_NAME=x"})
env = apps_dir() / "fileshare" / ".env"
assert not env.exists() or "COMPOSE_PROJECT_NAME" not in env.read_text()
def test_dollar_values_are_single_quoted_and_round_trip(tmp_path):
env = tmp_path / ".env"
values = {"PW": "pa$word", "B": "has space", "C": "it's $5", "D": "plain"}
installer.write_env(env, values)
text = env.read_text()
assert "PW='pa$word'" in text
assert "D=plain" in text
assert installer.read_env_values(env) == values
def test_manifest_name_must_be_app_name(tmp_path, fake_dirs):
src = _write_app_source(tmp_path, "evil", dict(VALID_MANIFEST, name="../evil"))
with pytest.raises(installer.InstallError, match="lowercase"):
installer.install_from(src)

View file

@ -316,14 +316,17 @@ def test_link_new_units_only_links_missing(updater, tmp_path, monkeypatch):
linked = updater._link_new_units(unit_dir) linked = updater._link_new_units(unit_dir)
assert linked == ["furtka-bar.timer"] assert linked == ["furtka-bar.timer"]
# Two calls for the newly-linked timer: systemctl link + systemctl enable. # Three calls for the newly-linked timer: link + enable + start. The
# The already-linked service is untouched. Timers need the follow-up # already-linked service is untouched. `enable` alone only arms the
# `enable` so self-updates that introduce new timers don't leave them # timer for the next boot; `start` makes it fire on the running box
# dormant — fresh installs get their enable via the webinstaller. # (26.20 left furtka-welcome.timer dead until reboot without it).
assert len(seen) == 2 assert len(seen) == 3
assert seen[0][:2] == ["systemctl", "link"] assert seen[0][:2] == ["systemctl", "link"]
assert seen[0][2].endswith("furtka-bar.timer") # Linked via the `current` symlink, not the versioned dir being scanned,
# so the link survives the next symlink flip / rollback.
assert seen[0][2] == str(updater.current_symlink() / "assets" / "systemd" / "furtka-bar.timer")
assert seen[1] == ["systemctl", "enable", "furtka-bar.timer"] assert seen[1] == ["systemctl", "enable", "furtka-bar.timer"]
assert seen[2] == ["systemctl", "start", "furtka-bar.timer"]
def test_extract_tarball_uses_data_filter_when_available(tmp_path, updater, monkeypatch): def test_extract_tarball_uses_data_filter_when_available(tmp_path, updater, monkeypatch):
@ -460,3 +463,79 @@ def test_check_update_raises_when_no_releases_published(updater, monkeypatch):
monkeypatch.setattr(updater, "_forgejo_api", lambda path: []) monkeypatch.setattr(updater, "_forgejo_api", lambda path: [])
with pytest.raises(updater.UpdateError, match="no releases"): with pytest.raises(updater.UpdateError, match="no releases"):
updater.check_update() updater.check_update()
def test_apply_update_rolls_back_on_oserror_and_restores_caddyfile(tmp_path, updater, monkeypatch):
"""A non-UpdateError after the symlink flip (disk full writing the
Caddyfile) used to leave the new version live with no rollback."""
versions = updater.versions_dir()
versions.mkdir(parents=True)
(versions / "26.0-alpha").mkdir()
current = updater.current_symlink()
current.symlink_to(versions / "26.0-alpha")
caddyfile = tmp_path / "etc_caddy" / "Caddyfile"
caddyfile.parent.mkdir()
caddyfile.write_text("# old routes\n")
tar = tmp_path / "t.tar.gz"
_make_tarball(tar, "26.1-alpha")
def full_disk(_source):
caddyfile.write_text("# half-written new routes\n")
raise OSError(28, "No space left on device")
monkeypatch.setattr(updater, "_refresh_caddyfile", full_disk)
monkeypatch.setattr(updater, "_run", lambda cmd: None)
import subprocess
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: None)
with pytest.raises(updater.UpdateError, match="rolled back"):
updater.apply_update(tar, "26.1-alpha")
assert current.resolve() == (versions / "26.0-alpha").resolve()
assert caddyfile.read_text() == "# old routes\n"
assert updater.read_state()["stage"] == "rolled_back"
def test_swap_symlink_replaces_existing_link(tmp_path, updater):
a, b = tmp_path / "a", tmp_path / "b"
a.mkdir()
b.mkdir()
link = tmp_path / "current"
link.symlink_to(a)
updater._swap_symlink(link, b)
assert link.resolve() == b.resolve()
assert not (tmp_path / "current.new").exists()
def test_run_update_writes_error_stage_on_download_failure(updater, monkeypatch):
class _Check:
update_available = True
latest = "26.1-alpha"
current = "26.0-alpha"
tarball_url = "http://x/t.tar.gz"
sha256_url = "http://x/t.sha256"
def fake_check():
return _Check()
monkeypatch.setattr(updater, "check_update", fake_check)
def boom(url, dest):
raise OSError(28, "No space left on device")
monkeypatch.setattr(updater, "_download", boom)
with pytest.raises(updater.UpdateError, match="No space left"):
updater.run_update()
state = updater.read_state()
assert state["stage"] == "error"
assert "No space left" in state["reason"]
def test_health_check_survives_read_timeout(updater, monkeypatch):
def slow(url, timeout):
raise TimeoutError("timed out")
monkeypatch.setattr("urllib.request.urlopen", slow)
monkeypatch.setattr(updater.time, "sleep", lambda _s: None)
assert updater._health_check("http://127.0.0.1:7000/api/apps", deadline_s=0.05) is False

View file

@ -227,6 +227,25 @@ def test_systemd_units_reference_current_paths():
) )
def test_welcome_banner_is_timer_driven():
"""The console banner must keep refreshing so the IP fallback is always
on screen. A oneshot with RemainAfterExit=yes never re-runs from a timer
(the unit stays 'active'), and a network-online dependency would delay the
first paint — both regressions the hardware bench hit in 26.19."""
timer = (ASSETS / "systemd" / "furtka-welcome.timer").read_text()
assert "OnUnitActiveSec=" in timer
assert "furtka-welcome.timer" in app._FURTKA_UNITS
service = (ASSETS / "systemd" / "furtka-welcome.service").read_text()
assert "RemainAfterExit" not in service
assert "network-online.target" not in service
def test_welcome_banner_always_prints_fallback_line():
script = (ASSETS / "bin" / "furtka-welcome").read_text()
assert "no IP address yet" in script
assert "fallback" in script
def test_read_asset_raises_for_missing_file(): def test_read_asset_raises_for_missing_file():
with pytest.raises(FileNotFoundError): with pytest.raises(FileNotFoundError):
app._read_asset("does/not/exist.html") app._read_asset("does/not/exist.html")
@ -259,3 +278,12 @@ def test_post_install_writes_users_json_with_hashed_password(install_cmds):
# Hash is a real werkzeug hash, not the plaintext password. # Hash is a real werkzeug hash, not the plaintext password.
assert parsed["admin"]["hash"] != "test-admin-pw" assert parsed["admin"]["hash"] != "test-admin-pw"
assert check_password_hash(parsed["admin"]["hash"], "test-admin-pw") assert check_password_hash(parsed["admin"]["hash"], "test-admin-pw")
def test_catalog_sync_service_retries_on_failure():
"""First sync at boot can race DHCPv4 (IPv6 makes network-online fire
early); the unit must retry rather than wait for the daily timer."""
body = (ASSETS / "systemd" / "furtka-catalog-sync.service").read_text()
assert "Restart=on-failure" in body
assert "RestartSec=" in body
assert "StartLimitBurst=" in body

View file

@ -8,9 +8,12 @@ import os
import re import re
import subprocess import subprocess
import sys import sys
import threading
import traceback
from datetime import UTC from datetime import UTC
from pathlib import Path from pathlib import Path
from diskprep import release_disk
from drives import list_scored_devices from drives import list_scored_devices
from flask import Flask, jsonify, redirect, render_template, request, url_for from flask import Flask, jsonify, redirect, render_template, request, url_for
@ -75,6 +78,35 @@ settings = {
HOSTNAME_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$") HOSTNAME_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")
USERNAME_RE = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$") USERNAME_RE = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$")
# Accounts that already exist on the installed system (base + the packages
# we install). Picking one of these either fails user creation mid-install
# (root) or makes the admin collide with a service account (caddy, docker…).
RESERVED_USERNAMES = frozenset(
{
"root",
"bin",
"daemon",
"mail",
"ftp",
"http",
"nobody",
"dbus",
"git",
"uuidd",
"avahi",
"caddy",
"docker",
"polkitd",
"rtkit",
"systemd-coredump",
"systemd-journal-remote",
"systemd-network",
"systemd-oom",
"systemd-resolve",
"systemd-timesync",
}
)
# Ordered phase markers for the install progress bar. Each tuple is # Ordered phase markers for the install progress bar. Each tuple is
# (substring to search for in the archinstall log, progress percent when # (substring to search for in the archinstall log, progress percent when
# reached, user-facing label). Pick the furthest phase whose marker is # reached, user-facing label). Pick the furthest phase whose marker is
@ -96,8 +128,24 @@ PROGRESS_PHASES = [
PROGRESS_ERROR_MARKERS = ("Traceback (most recent call last)", "archinstall: error:") PROGRESS_ERROR_MARKERS = ("Traceback (most recent call last)", "archinstall: error:")
# Known failure signatures → a phase label that tells the user what to do
# instead of a bare "open Show details". Checked in order; first hit wins.
PROGRESS_ERROR_HINTS = [
(
"unable to inform the kernel of the change",
"Installation failed — the disk was still in use (old LVM/RAID/swap "
"from a previous system). Reboot the installer and try again",
),
]
def parse_install_progress(log):
def parse_install_progress(log, exit_code=None):
"""Progress dict from the install log.
`exit_code` is archinstall's return code once the process has ended
(None while running or unknown). A non-zero exit is an error even when
the log has no Traceback — OOM kills and signals print nothing.
"""
percent = 2 percent = 2
phase = "Starting up…" phase = "Starting up…"
for marker, pct, label in PROGRESS_PHASES: for marker, pct, label in PROGRESS_PHASES:
@ -107,29 +155,48 @@ def parse_install_progress(log):
if percent >= 100: if percent >= 100:
status = "done" status = "done"
elif any(m in log for m in PROGRESS_ERROR_MARKERS): elif any(m in log for m in PROGRESS_ERROR_MARKERS) or exit_code not in (None, 0):
status = "error" status = "error"
phase = "Installation failed — open Show details below" phase = "Installation failed — open Show details below"
for marker, hint in PROGRESS_ERROR_HINTS:
if marker in log:
phase = hint
break
else: else:
status = "running" status = "running"
if percent == 18:
# archinstall waits for NTP time sync with no timeout. Only a hint:
# it usually clears on its own within a minute.
phase = (
"Syncing time and packages — if this takes more than a few "
"minutes, check that the network cable is plugged in"
)
return {"percent": percent, "phase": phase, "status": status} return {"percent": percent, "phase": phase, "status": status}
def validate_step1(form): def validate_step1(form):
errors = [] errors = []
# Lowercase instead of rejecting "Furtka" / "Daniel" — Linux needs
# lowercase, people type names the way they write them.
values = { values = {
"hostname": form.get("hostname", "").strip(), "hostname": form.get("hostname", "").strip().lower(),
"username": form.get("username", "").strip(), "username": form.get("username", "").strip().lower(),
"password": form.get("password", ""), "password": form.get("password", ""),
"language": form.get("language", ""), "language": form.get("language", ""),
} }
password2 = form.get("password2", "") password2 = form.get("password2", "")
if not HOSTNAME_RE.match(values["hostname"]): if not HOSTNAME_RE.match(values["hostname"]):
errors.append("Hostname must be lowercase letters, digits, hyphens (max 63 chars).") errors.append(
"The server name can only use letters, digits and hyphens (no spaces, max 63)."
)
if not USERNAME_RE.match(values["username"]): if not USERNAME_RE.match(values["username"]):
errors.append("Username must start with a letter or underscore, lowercase only.") errors.append(
"The username must start with a letter and can only use letters, digits, - and _."
)
elif values["username"] in RESERVED_USERNAMES:
errors.append(f"“{values['username']}” is reserved by the system — please pick another.")
if len(values["password"]) < 8: if len(values["password"]) < 8:
errors.append("Password must be at least 8 characters.") errors.append("Password must be at least 8 characters.")
if values["password"] != password2: if values["password"] != password2:
@ -264,6 +331,9 @@ _FURTKA_UNITS = (
"furtka-status.service", "furtka-status.service",
"furtka-status.timer", "furtka-status.timer",
"furtka-welcome.service", "furtka-welcome.service",
# Re-runs the welcome banner every few seconds so the console always
# shows the current IP fallback (or a "no IP yet" hint).
"furtka-welcome.timer",
# Daily apps-catalog pull. Timer drives the service; the .service itself # Daily apps-catalog pull. Timer drives the service; the .service itself
# is oneshot and also callable ad-hoc via `furtka catalog sync`. # is oneshot and also callable ad-hoc via `furtka catalog sync`.
"furtka-catalog-sync.service", "furtka-catalog-sync.service",
@ -420,6 +490,16 @@ def _post_install_commands(hostname, admin_username, admin_password):
# Initial status.json so Caddy doesn't 404 before furtka-status fires. # Initial status.json so Caddy doesn't 404 before furtka-status fires.
_write_file_cmd("/var/lib/furtka/status.json", _read_asset("www/status.json")), _write_file_cmd("/var/lib/furtka/status.json", _read_asset("www/status.json")),
nss_sed, nss_sed,
# The installed system inherits the live ISO's networkd config
# (DHCP on every Ethernet + Wi-Fi link) but not its wait-online
# drop-in, so a board with a second, unplugged NIC stalled boot for
# up to 2 minutes before network-online.target. Any one link is
# enough for a home server.
_write_file_cmd(
"/etc/systemd/system/systemd-networkd-wait-online.service.d/any-interface.conf",
"[Service]\nExecStart=\n"
"ExecStart=/usr/lib/systemd/systemd-networkd-wait-online --any\n",
),
# Resource manager bootstrap: extract tarball → versions/<ver>/, # Resource manager bootstrap: extract tarball → versions/<ver>/,
# symlink current, install wrapper, systemctl-link unit files. # symlink current, install wrapper, systemctl-link unit files.
*_resource_manager_commands(), *_resource_manager_commands(),
@ -481,7 +561,7 @@ def build_archinstall_config(s):
"docker", "docker",
# Base OS post-install services. Only packaged units go here — # Base OS post-install services. Only packaged units go here —
# archinstall runs `systemctl enable` on this list *before* # archinstall runs `systemctl enable` on this list *before*
# custom_commands, so our own furtka-welcome + furtka-status.timer # custom_commands, so our own furtka-welcome.timer + furtka-status.timer
# units (written in custom_commands) are enabled there instead. # units (written in custom_commands) are enabled there instead.
"caddy", "caddy",
"avahi-daemon", "avahi-daemon",
@ -537,7 +617,9 @@ def write_install_files(s, state_dir):
def spawn_archinstall(config_path, creds_path, log_path): def spawn_archinstall(config_path, creds_path, log_path):
log_fh = open(log_path, "wb") # Append: install_run() already truncated the log and may have written
# the disk-release lines we want to keep visible above archinstall's own.
log_fh = open(log_path, "ab")
return subprocess.Popen( return subprocess.Popen(
[ [
"archinstall", "archinstall",
@ -553,6 +635,16 @@ def spawn_archinstall(config_path, creds_path, log_path):
) )
@app.before_request
def _stay_on_progress_while_installing():
# While archinstall runs, the wizard pages would let a second browser
# (or Back) edit settings that no longer apply — show the progress.
running = _install_proc is not None and _install_proc.poll() is None
if running and request.endpoint in ("install_step_1", "install_step_2", "install_overview"):
return redirect(url_for("install_log_view"))
return None
@app.route("/") @app.route("/")
def home(): def home():
return redirect(url_for("install_step_1")) return redirect(url_for("install_step_1"))
@ -577,64 +669,127 @@ def install_step_1():
) )
# Size/type of the disk picked in step 2, for the confirm page (re-probing
# would re-run smartctl on every disk).
_selected_drive: dict = {}
@app.route("/install/step2", methods=["GET", "POST"]) @app.route("/install/step2", methods=["GET", "POST"])
def install_step_2(): def install_step_2():
drives = list_scored_devices()
error = ""
if request.method == "POST": if request.method == "POST":
boot_drive = request.form.get("boot_drive", "").strip() boot_drive = request.form.get("boot_drive", "").strip()
if boot_drive: # Only accept a disk we actually offered — never a free-form path.
picked = next((d for d in drives if d["name"] == boot_drive and d["usable"]), None)
if picked:
settings["boot_drive"] = boot_drive settings["boot_drive"] = boot_drive
_selected_drive.clear()
_selected_drive.update(picked)
return redirect(url_for("install_overview")) return redirect(url_for("install_overview"))
error = "Please pick one of the disks below."
return render_template( return render_template(
"install/step2.html", "install/step2.html",
drives=list_scored_devices(), drives=drives,
selected=settings.get("boot_drive", ""), selected=settings.get("boot_drive", ""),
error=error,
) )
@app.route("/install/overview") @app.route("/install/overview")
def install_overview(): def install_overview():
if not settings["boot_drive"] or not settings["username"]:
return redirect(url_for("install_step_1"))
masked = {**settings, "password": "•" * 8 if settings["password"] else ""} masked = {**settings, "password": "•" * 8 if settings["password"] else ""}
return render_template("install/overview.html", settings=masked) drive = _selected_drive if _selected_drive.get("name") == settings["boot_drive"] else None
language = LANGUAGES.get(settings["language"], {}).get("label", settings["language"])
return render_template(
"install/overview.html", settings=masked, drive=drive, language_label=language
)
# The running archinstall process, so a second click (double-submit, Back +
# Install again, a second browser) can't start another install on the same
# disk, and so the progress page can see an exit without a Traceback.
_install_proc = None
_install_lock = threading.Lock()
def _install_exit_code():
return None if _install_proc is None else _install_proc.poll()
def _read_install_log():
log = INSTALL_LOG.read_text(errors="replace") if INSTALL_LOG.exists() else ""
code = _install_exit_code()
if code not in (None, 0):
log += f"\n\narchinstall stopped unexpectedly (exit code {code}).\n"
return log, code
@app.route("/install/run", methods=["POST"]) @app.route("/install/run", methods=["POST"])
def install_run(): def install_run():
if not settings["boot_drive"] or not settings["username"] or not settings["password"]: if not settings["boot_drive"] or not settings["username"] or not settings["password"]:
return redirect(url_for("install_step_1")) return redirect(url_for("install_step_1"))
config_path, creds_path = write_install_files(settings, STATE_DIR) with _install_lock:
if _install_proc is not None and _install_proc.poll() is None:
return redirect(url_for("install_log_view"))
return _start_install()
def _start_install():
global _install_proc
STATE_DIR.mkdir(parents=True, exist_ok=True)
INSTALL_LOG.write_bytes(b"") INSTALL_LOG.write_bytes(b"")
try:
config_path, creds_path = write_install_files(settings, STATE_DIR)
except Exception:
# archinstall's disk probe raises on disks it can't parse (broken
# GPT, odd card readers). Put the traceback where the progress page
# finds it instead of a bare "Internal Server Error".
with INSTALL_LOG.open("a") as fh:
fh.write(
f"Could not read the layout of {settings['boot_drive']}.\n" + traceback.format_exc()
)
return redirect(url_for("install_log_view"))
if os.environ.get("FURTKA_DRY_RUN") == "1": if os.environ.get("FURTKA_DRY_RUN") == "1":
INSTALL_LOG.write_text( INSTALL_LOG.write_text(
f"DRY RUN: would exec archinstall --config {config_path} " f"DRY RUN: would exec archinstall --config {config_path} "
f"--creds {creds_path} --silent\n" f"--creds {creds_path} --silent\n"
) )
else: else:
spawn_archinstall(config_path, creds_path, INSTALL_LOG) # Free the target disk first: udev auto-activates old LVM/RAID/swap
# on the live ISO, and archinstall can't re-read a partition table
# while those hold the partitions open (first hardware bench, 26.19).
with INSTALL_LOG.open("a") as fh:
fh.write("\n".join(release_disk(settings["boot_drive"])) + "\n\n")
_install_proc = spawn_archinstall(config_path, creds_path, INSTALL_LOG)
return redirect(url_for("install_log_view")) return redirect(url_for("install_log_view"))
@app.route("/install/log") @app.route("/install/log")
def install_log_view(): def install_log_view():
log = INSTALL_LOG.read_text() if INSTALL_LOG.exists() else "" log, code = _read_install_log()
return render_template( return render_template(
"install/log.html", "install/log.html",
log=log, log=log,
progress=parse_install_progress(log), progress=parse_install_progress(log, code),
hostname=settings["hostname"],
) )
@app.route("/install/log.json") @app.route("/install/log.json")
def install_log_json(): def install_log_json():
log = INSTALL_LOG.read_text() if INSTALL_LOG.exists() else "" log, code = _read_install_log()
return jsonify(log=log, progress=parse_install_progress(log)) return jsonify(log=log, progress=parse_install_progress(log, code))
@app.route("/install/reboot", methods=["POST"]) @app.route("/install/reboot", methods=["POST"])
def install_reboot(): def install_reboot():
# Only allow rebooting once the install has actually finished — we don't # Only allow rebooting once the install has actually finished — we don't
# want a panicked click during install to reboot mid-pacstrap. # want a panicked click during install to reboot mid-pacstrap.
log = INSTALL_LOG.read_text() if INSTALL_LOG.exists() else "" log, code = _read_install_log()
if parse_install_progress(log)["status"] != "done": if parse_install_progress(log, code)["status"] != "done":
return redirect(url_for("install_log_view")) return redirect(url_for("install_log_view"))
# Delay reboot a few seconds so the browser can finish fetching CSS / assets # Delay reboot a few seconds so the browser can finish fetching CSS / assets
# for the rebooting page before the Flask server (and network) go away. # for the rebooting page before the Flask server (and network) go away.

125
webinstaller/diskprep.py Normal file
View file

@ -0,0 +1,125 @@
"""Release a target disk before handing it to archinstall.
The live ISO ships lvm2, mdadm, dmraid and cryptsetup (releng defaults), and
udev happily auto-activates whatever it finds on attached disks at boot. A
drive that used to live in another machine — the first hardware bench had an
old Proxmox VE install on its SSD — therefore comes up with device-mapper
volumes (or swap, or md arrays) stacked on top of its partitions. archinstall
then writes the new partition table fine but `BLKRRPART` fails with
Partition(s) 2, 3 on /dev/sda have been written, but we have been unable
to inform the kernel of the change, probably because it/they are in use.
and the install dies at 12 %. Nothing on a fresh VM ever triggers this,
which is why it survived every smoke run.
`release_disk()` tears that stack down leaves-first (swapoff, umount, dmsetup
remove / mdadm --stop), wipes signatures and asks the kernel to re-read the
partition table. Every step is logged so the install log shows what was
done. All commands are best-effort: the point is to leave archinstall a
disk the kernel can re-partition, not to be a full disk-management tool.
"""
import re
import subprocess
_PAIR_RE = re.compile(r'(\w+)="([^"]*)"')
def _run(cmd, run):
try:
result = run(cmd, capture_output=True, text=True)
except FileNotFoundError:
return 127, f"{cmd[0]}: command not found"
out = ((result.stdout or "") + (result.stderr or "")).strip()
return result.returncode, out
def _parse_pairs(output):
"""Parse `lsblk -P` key="value" lines into dicts."""
rows = []
for line in output.splitlines():
row = {}
for match in _PAIR_RE.finditer(line):
row[match.group(1)] = match.group(2)
if row.get("NAME"):
rows.append(row)
return rows
def _children(disk, run):
"""Everything stacked on `disk`, in lsblk tree order, disk itself excluded."""
rc, out = _run(["lsblk", "-lnP", "-o", "NAME,TYPE,MOUNTPOINTS", disk], run)
if rc != 0:
return []
rows = _parse_pairs(out)
seen = set()
children = []
for row in rows:
if row.get("TYPE") == "disk" or row["NAME"] in seen:
continue
seen.add(row["NAME"])
children.append(row)
return children
def release_disk(disk, run=subprocess.run):
"""Free `disk` (e.g. "/dev/sda") of anything that would block a
partition-table reload. Returns the log lines describing what happened."""
if not disk.startswith("/dev/"):
disk = f"/dev/{disk}"
lines = [f"Releasing {disk} before install…"]
children = _children(disk, run)
if not children:
lines.append(" nothing stacked on the disk — good.")
else:
lines.append(" found: " + ", ".join(f"{c['NAME']} ({c['TYPE']})" for c in children))
# Leaves first: mountpoints and swap sit on the outermost devices.
for child in reversed(children):
dev = f"/dev/{child['NAME']}"
mounts = child.get("MOUNTPOINTS", "")
if "[SWAP]" in mounts:
rc, out = _run(["swapoff", dev], run)
lines.append(f" swapoff {dev}: {'ok' if rc == 0 else out}")
elif mounts:
rc, out = _run(["umount", "--all-targets", dev], run)
lines.append(f" umount {dev}: {'ok' if rc == 0 else out}")
# Device-mapper (LVM, thin pools, LUKS) and md stacks. Thin pools need
# their thin LVs gone before the pool goes, and lsblk lists shared nodes
# more than once, so go leaves-first and retry a couple of passes until
# nothing dm/md-shaped is left.
for _ in range(3):
stacked = [c for c in reversed(_children(disk, run)) if c["TYPE"] != "part"]
if not stacked:
break
for child in stacked:
name, typ = child["NAME"], child["TYPE"]
if typ.startswith("raid") or name.startswith("md"):
cmd = ["mdadm", "--stop", f"/dev/{name}"]
else:
cmd = ["dmsetup", "remove", name]
rc, out = _run(cmd, run)
lines.append(f" {' '.join(cmd)}: {'ok' if rc == 0 else out}")
leftovers = [c["NAME"] for c in _children(disk, run) if c["TYPE"] != "part"]
if leftovers:
lines.append(
" WARNING: still in use after cleanup: "
+ ", ".join(leftovers)
+ " — the install may fail to re-read the partition table."
)
# Old signatures (LVM PV headers, RAID superblocks) would otherwise be
# re-activated by udev the moment the kernel re-reads the table.
for child in reversed(_children(disk, run)):
if child["TYPE"] == "part":
rc, out = _run(["wipefs", "-a", f"/dev/{child['NAME']}"], run)
lines.append(f" wipefs /dev/{child['NAME']}: {'ok' if rc == 0 else out}")
rc, out = _run(["wipefs", "-a", disk], run)
lines.append(f" wipefs {disk}: {'ok' if rc == 0 else out}")
rc, out = _run(["blockdev", "--rereadpt", disk], run)
lines.append(f" re-read partition table: {'ok' if rc == 0 else out}")
return lines

View file

@ -1,55 +1,89 @@
import json
import re
import subprocess import subprocess
from pathlib import Path
# Smallest disk we offer as an install target. The base system plus Docker
# needs ~6 GB; the rest is headroom for images and app data.
MIN_DISK_GB = 16
# MBR (what archinstall lays out on BIOS boxes) can't address past 2 TiB.
MBR_LIMIT_GB = 2048
# Block devices lsblk reports as TYPE=disk that are never install targets:
# compressed RAM swap and the eMMC hardware boot / RPMB areas (4 MB, usually
# read-only) that show up next to mmcblk0 on every eMMC mini-PC and laptop.
_PSEUDO_DISK_RE = re.compile(r"^(zram\d+|mmcblk\d+boot\d+|mmcblk\d+rpmb)$")
_SUBPROCESS_TIMEOUT = 15
def _run(cmd, timeout=_SUBPROCESS_TIMEOUT):
"""subprocess.run with a timeout; None when the tool is missing or hangs."""
try:
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
except (FileNotFoundError, subprocess.TimeoutExpired):
return None
def _parent_disk(device):
result = _run(["lsblk", "-no", "PKNAME", device])
if result is None or result.returncode != 0:
return None
lines = [line.strip() for line in result.stdout.splitlines() if line.strip()]
return lines[0] if lines else None
def _boot_device_from_cmdline(cmdline):
"""Resolve the boot partition from archiso's kernel args, or None."""
args = dict(arg.split("=", 1) for arg in cmdline.split() if "=" in arg)
if args.get("archisodevice", "").startswith("/dev/"):
return args["archisodevice"]
if args.get("archisosearchuuid"):
cmd = ["blkid", "-U", args["archisosearchuuid"]]
elif args.get("archisolabel"):
cmd = ["blkid", "-L", args["archisolabel"]]
else:
return None
result = _run(cmd)
if result is None or result.returncode != 0:
return None
return result.stdout.strip() or None
def _boot_disk_name(): def _boot_disk_name():
"""Return the parent disk name of the live-ISO boot media (e.g. "sdb"), or None. """Return the parent disk name of the live-ISO boot media (e.g. "sdb"), or None.
On a normal box `/run/archiso/bootmnt` does not exist and we return None, On bare metal booted from USB this is the stick we booted from — we want
leaving the device list untouched. On bare metal booted from USB this is to filter it out so the user can't accidentally pick it as the install
the stick we booted from — we want to filter it out so the user can't target. Primary source is the mount behind /run/archiso/bootmnt; when
accidentally pick it as the install target. that's gone (copytoram unmounts it) fall back to the UUID/label archiso
put on the kernel command line.
""" """
partition = None
result = _run(["findmnt", "-no", "SOURCE", "/run/archiso/bootmnt"])
if result is not None and result.returncode == 0:
partition = result.stdout.strip() or None
if partition is None:
try: try:
result = subprocess.run( partition = _boot_device_from_cmdline(Path("/proc/cmdline").read_text())
["findmnt", "-no", "SOURCE", "/run/archiso/bootmnt"], except OSError:
capture_output=True, partition = None
text=True, if partition is None:
)
except FileNotFoundError:
return None return None
if result.returncode != 0: # A stick written with dd has the ISO filesystem on the whole disk, where
return None # PKNAME is empty — the device itself is then the disk.
partition = result.stdout.strip() return _parent_disk(partition) or Path(partition).name
if not partition:
return None
try:
parent = subprocess.run(
["lsblk", "-no", "PKNAME", partition],
capture_output=True,
text=True,
)
except FileNotFoundError:
return None
if parent.returncode != 0:
return None
name = parent.stdout.strip().splitlines()[0] if parent.stdout.strip() else ""
return name or None
def _smart_status(device): def _smart_status(device):
try: result = _run(["smartctl", "-H", device])
result = subprocess.run( if result is None:
["smartctl", "-H", device],
capture_output=True,
)
output = result.stdout.decode()
if "PASSED" in output:
return "passed"
elif "FAILED" in output:
return "failed"
return "unknown" return "unknown"
except Exception as e: if "PASSED" in result.stdout:
print(f"Error checking SMART status for {device}: {e}") return "passed"
if "FAILED" in result.stdout:
return "failed"
return "unknown" return "unknown"
@ -60,40 +94,33 @@ _HEALTH_LABEL = {
"unknown": "Status unknown", "unknown": "Status unknown",
} }
_TYPE_SCORE = {"NVMe": 15, "SSD": 10, "eMMC": 8, "HDD": 5, "USB": 0}
def get_drive_health(device): def get_drive_health(device):
return _HEALTH_SCORE[_smart_status(device)] return _HEALTH_SCORE[_smart_status(device)]
def get_drive_type_score(device): def classify_drive(name, tran="", rota=None, removable=False):
name = device.lower() """Human label for a disk: NVMe, SSD, eMMC, HDD or USB.
if "nvme" in name:
return 15
if "ssd" in name:
return 10
return 5
Uses lsblk's transport and rotational flags — the kernel name alone
def get_drive_type_label(device): can't tell an SSD from a spinning disk (both are sdX).
name = device.lower() """
if "nvme" in name: tran = (tran or "").lower()
if tran == "usb" or (removable and not name.startswith("mmcblk")):
return "USB"
if tran == "nvme" or name.startswith("nvme"):
return "NVMe" return "NVMe"
if "ssd" in name: if tran == "mmc" or name.startswith("mmcblk"):
return "eMMC"
if rota is False:
return "SSD" return "SSD"
return "HDD" return "HDD"
def parse_size_gb(size_str): def get_drive_type_score(type_label):
size_str = size_str.strip().upper().replace(",", ".") return _TYPE_SCORE.get(type_label, 5)
if not size_str:
return None
if size_str.endswith("T"):
return float(size_str[:-1]) * 1024
if size_str.endswith("G"):
return float(size_str[:-1])
if size_str.endswith("M"):
return float(size_str[:-1]) / 1024
return None
def get_size_score(size_gb): def get_size_score(size_gb):
@ -106,61 +133,98 @@ def get_size_score(size_gb):
return 10 return 10
def score_device(device, size_gb): def format_size(size_bytes):
return get_drive_type_score(device) + get_drive_health(device) + get_size_score(size_gb) gb = size_bytes / 1024**3
if gb >= 1024:
return f"{gb / 1024:.1f} TB".replace(".0 ", " ")
if gb >= 10:
return f"{gb:.0f} GB"
return f"{gb:.1f} GB"
def parse_lsblk_output(output, boot_disk=None): def _flag(value):
"""Parse `lsblk -dn -o NAME,SIZE,TYPE` output into scored device dicts. # util-linux ≥2.37 emits JSON booleans; older builds emit "0"/"1".
if isinstance(value, bool):
return value
return str(value).strip() in ("1", "true")
Keeps only TYPE=disk so the live ISO's own squashfs (loop) and the boot
CD-ROM (rom) don't show up as install targets. If `boot_disk` is given, def parse_lsblk_json(output, boot_disk=None, uefi=True):
that disk is also dropped — it's the USB stick the live ISO booted from """Parse `lsblk -J -d -b -o NAME,SIZE,TYPE,RO,RM,TRAN,ROTA` into device dicts.
on bare metal, where it appears as TYPE=disk and would otherwise be a
valid-looking install target. Keeps only real, writable, non-empty TYPE=disk devices (drops the live
ISO's loop/rom, zram, eMMC boot areas, empty card-reader slots) and the
USB stick we booted from. Disks too small to install on stay in the list
with `usable=False` and a reason so the user sees why they're greyed out.
Sorted best-first; unusable disks last.
""" """
try:
entries = json.loads(output or "{}").get("blockdevices", [])
except json.JSONDecodeError:
return []
devices = [] devices = []
for line in output.strip().split("\n"): for e in entries:
if not line: name = e.get("name") or ""
if e.get("type") != "disk" or not name:
continue continue
parts = line.split() if _PSEUDO_DISK_RE.match(name) or (boot_disk and name == boot_disk):
if len(parts) < 3:
continue continue
name, size, dev_type = parts[0], parts[1], parts[2] try:
if dev_type != "disk": size_bytes = int(e.get("size") or 0)
continue except (TypeError, ValueError):
if boot_disk and name == boot_disk: size_bytes = 0
if size_bytes <= 0 or _flag(e.get("ro", False)):
continue continue
size_gb = size_bytes / 1024**3
type_label = classify_drive(
name,
tran=e.get("tran") or "",
rota=_flag(e.get("rota")) if e.get("rota") is not None else None,
removable=_flag(e.get("rm", False)),
)
device = f"/dev/{name}" device = f"/dev/{name}"
size_gb = parse_size_gb(size) usable = size_gb >= MIN_DISK_GB
status = _smart_status(device) status = _smart_status(device) if usable else "unknown"
score = get_drive_type_score(device) + _HEALTH_SCORE[status] + get_size_score(size_gb) warning = ""
if not uefi and size_gb > MBR_LIMIT_GB:
warning = (
"This computer starts in legacy (BIOS) mode, which can only use "
"the first 2 TB of this disk. Switch the firmware to UEFI to use all of it."
)
elif type_label == "USB":
warning = (
"This is a USB drive — it works, but an internal disk is faster and more reliable."
)
devices.append( devices.append(
{ {
"name": device, "name": device,
"size": size, "size": format_size(size_bytes),
"type_label": get_drive_type_label(device), "size_gb": size_gb,
"type_label": type_label,
"health_label": _HEALTH_LABEL[status], "health_label": _HEALTH_LABEL[status],
"score": score, "score": get_drive_type_score(type_label)
+ _HEALTH_SCORE[status]
+ get_size_score(size_gb),
"usable": usable,
"reason": "" if usable else f"Too small — Furtka needs at least {MIN_DISK_GB} GB",
"warning": warning,
} }
) )
devices.sort(key=lambda d: d["score"], reverse=True) devices.sort(key=lambda d: (d["usable"], d["score"]), reverse=True)
return devices return devices
def list_scored_devices(): def list_scored_devices():
"""Return [{name, size, score}, ...] for all physical disks, highest score first.""" """Return [{name, size, score, usable, ...}, ...] for all physical disks, best first."""
try: result = _run(["lsblk", "-J", "-d", "-b", "-o", "NAME,SIZE,TYPE,RO,RM,TRAN,ROTA"])
result = subprocess.run( if result is None or result.returncode != 0:
["lsblk", "-dn", "-o", "NAME,SIZE,TYPE"], print(f"Error listing devices: {result.stderr if result else 'lsblk unavailable'}")
capture_output=True,
text=True,
check=True,
)
except subprocess.CalledProcessError as e:
print(f"Error listing devices: {e}")
return [] return []
return parse_lsblk_output(result.stdout, boot_disk=_boot_disk_name()) return parse_lsblk_json(
result.stdout,
boot_disk=_boot_disk_name(),
uefi=Path("/sys/firmware/efi").exists(),
)
def main(): def main():
@ -168,11 +232,11 @@ def main():
if not devices: if not devices:
print("No storage devices found.") print("No storage devices found.")
return return
print(f"\n{'Device':<20} {'Size':<10} {'Score'}") print(f"\n{'Device':<20} {'Size':<10} {'Type':<6} {'Score'}")
print("-" * 40) print("-" * 46)
for d in devices: for d in devices:
print(f"{d['name']:<20} {d['size']:<10} {d['score']}") note = "" if d["usable"] else f" ({d['reason']})"
print(f"\nBest drive for boot: {devices[0]['name']} (score {devices[0]['score']})") print(f"{d['name']:<20} {d['size']:<10} {d['type_label']:<6} {d['score']}{note}")
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><style>svg{color:#c03a28}@media (prefers-color-scheme:dark){svg{color:#ff6b56}}</style><path fill="currentColor" fill-rule="evenodd" d="M5 60V27a27 27 0 0 1 54 0v33Z M19.5 60V26A10 10 0 0 1 29.5 16H46v10H32a2.5 2.5 0 0 0-2.5 2.5V32H46v10H29.5v18Z"/></svg>

Before

Width:  |  Height:  |  Size: 316 B

After

Width:  |  Height:  |  Size: 316 B

View file

@ -190,9 +190,17 @@ select:focus {
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
flex-wrap: wrap;
cursor: pointer; cursor: pointer;
transition: border-color 120ms, background 120ms; transition: border-color 120ms, background 120ms;
} }
.drive-disabled { cursor: not-allowed; opacity: 0.6; }
.drive-disabled:hover { border-color: var(--border); }
.drive .drive-note {
flex-basis: 100%;
font-size: 0.82rem;
color: var(--fg-muted);
}
.drive:hover { border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); } .drive:hover { border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); }
.drive input[type="radio"] { .drive input[type="radio"] {
accent-color: var(--accent); accent-color: var(--accent);
@ -421,3 +429,11 @@ select:focus {
outline-offset: 3px; outline-offset: 3px;
border-radius: 2px; border-radius: 2px;
} }
/* "Furtka is ready" — the two steps before the restart button (log.html). */
.next-steps {
margin: 1.5rem 0 1rem;
padding-left: 1.4rem;
line-height: 1.5;
}
.next-steps li { margin-bottom: 0.6rem; }

View file

@ -4,6 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Furtka Installer{% endblock %}</title> <title>{% block title %}Furtka Installer{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
{% block head_extra %}{% endblock %} {% block head_extra %}{% endblock %}
</head> </head>
@ -11,12 +12,8 @@
<header class="site-header"> <header class="site-header">
<div class="container"> <div class="container">
<a href="{{ url_for('install_step_1') }}" class="site-title" aria-label="Furtka Installer — restart"> <a href="{{ url_for('install_step_1') }}" class="site-title" aria-label="Furtka Installer — restart">
<svg class="gate-mark" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" <svg class="gate-mark" width="24" height="24" viewBox="0 0 64 64" aria-hidden="true">
stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <path fill="currentColor" fill-rule="evenodd" d="M5 60V27a27 27 0 0 1 54 0v33Z M19.5 60V26A10 10 0 0 1 29.5 16H46v10H32a2.5 2.5 0 0 0-2.5 2.5V32H46v10H29.5v18Z"/>
<path d="M4 20 V12 A9 9 0 0 1 20 12 V20"/>
<line x1="12" y1="5" x2="12" y2="20"/>
<line x1="3" y1="20" x2="21" y2="20"/>
<line x1="15" y1="12" x2="15" y2="14.5"/>
</svg> </svg>
<span class="wordmark">Furtka<span class="sep">·</span>Installer</span> <span class="wordmark">Furtka<span class="sep">·</span>Installer</span>
</a> </a>

View file

@ -17,16 +17,38 @@
{% else %}Installing Furtka{% endif %} {% else %}Installing Furtka{% endif %}
</h1> </h1>
<p class="lede" id="install-lede"> <p class="lede" id="install-lede">
{% if progress.status == "done" %}Installation finished. <strong>Remove the installer USB / eject the ISO</strong>, then click Reboot. {% if progress.status == "done" %}Installation finished. Two more steps and Furtka is running from its own disk.
{% elif progress.status == "error" %}Something went wrong. Open the details below and share them so we can help. {% elif progress.status == "error" %}{{ progress.phase }}.
{% else %}This takes a few minutes. Don't close this page or power off the machine.{% endif %} {% else %}This takes 5–20 minutes, depending on your internet speed. Don't power off the machine — it's fine to close this page and come back.{% endif %}
</p> </p>
{% if progress.status == "done" %} {% if progress.status == "error" %}
<form method="post" action="{{ url_for('install_reboot') }}" <p>Your settings are kept, so you can simply try again. If it fails the same way twice,
onsubmit="return confirm('Have you removed the installer USB / ejected the ISO? Click OK to reboot.');"> open <em>Show details</em> below — the last lines of the log say what went wrong.</p>
<form method="post" action="{{ url_for('install_run') }}"
onsubmit="this.querySelector('button').disabled = true;">
<div class="actions"> <div class="actions">
<button type="submit" class="btn btn-primary">Reboot now</button> <button type="submit" class="btn btn-primary">Try again</button>
<a href="{{ url_for('install_step_2') }}" class="btn btn-link">Pick a different disk</a>
<a href="{{ url_for('install_step_1') }}" class="btn btn-link">Change settings</a>
</div>
</form>
{% endif %}
{% if progress.status == "done" %}
{# The USB-stick step has to be read *before* the click: the machine reboots
3 s after it, and most BIOSes boot the stick before the disk — so the
installer would just come back. Seen on the first hardware bench. #}
<ol class="next-steps">
<li><strong>Pull out the USB stick</strong> (or eject the installer ISO in your VM).
If it stays in, the computer will start this installer again instead of Furtka.</li>
<li>Click the button below. The computer restarts and comes back as
<strong>http://{{ hostname }}.local</strong> in about a minute.</li>
</ol>
<form method="post" action="{{ url_for('install_reboot') }}"
onsubmit="return confirm('USB stick removed? Click OK to restart the computer.');">
<div class="actions">
<button type="submit" class="btn btn-primary">USB stick is out — restart now</button>
</div> </div>
</form> </form>
{% endif %} {% endif %}
@ -34,7 +56,7 @@
<div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{ progress.percent }}"> <div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{ progress.percent }}">
<div id="progress-bar" class="progress-bar{% if progress.status == 'error' %} progress-bar-error{% endif %}{% if progress.status == 'done' %} progress-bar-done{% endif %}" style="width: {{ progress.percent }}%;"></div> <div id="progress-bar" class="progress-bar{% if progress.status == 'error' %} progress-bar-error{% endif %}{% if progress.status == 'done' %} progress-bar-done{% endif %}" style="width: {{ progress.percent }}%;"></div>
</div> </div>
<p class="progress-phase"><span id="progress-phase">{{ progress.phase }}</span> · <span id="progress-percent">{{ progress.percent }}</span>%</p> <p class="progress-phase" aria-live="polite"><span id="progress-phase">{{ progress.phase }}</span> · <span id="progress-percent">{{ progress.percent }}</span>%</p>
<details class="log-details"> <details class="log-details">
<summary>Show details</summary> <summary>Show details</summary>
@ -50,17 +72,7 @@
var phaseEl = document.getElementById('progress-phase'); var phaseEl = document.getElementById('progress-phase');
var percentEl = document.getElementById('progress-percent'); var percentEl = document.getElementById('progress-percent');
var logEl = document.getElementById('install-log'); var logEl = document.getElementById('install-log');
var headingEl = document.getElementById('install-heading'); var failures = 0;
var ledeEl = document.getElementById('install-lede');
var HEADINGS = {
done: 'Furtka is ready',
error: 'Installation hit a snag',
};
var LEDES = {
done: 'Installation finished. Remove the USB / eject the installer image, then reboot.',
error: 'Something went wrong. Open the details below and share them so we can help.',
};
function tick() { function tick() {
fetch('{{ url_for("install_log_json") }}', { cache: 'no-store' }) fetch('{{ url_for("install_log_json") }}', { cache: 'no-store' })
@ -68,6 +80,7 @@
.then(function (data) { .then(function (data) {
var p = data.progress; var p = data.progress;
bar.style.width = p.percent + '%'; bar.style.width = p.percent + '%';
bar.parentNode.setAttribute('aria-valuenow', p.percent);
phaseEl.textContent = p.phase; phaseEl.textContent = p.phase;
percentEl.textContent = p.percent; percentEl.textContent = p.percent;
if (logEl.textContent !== data.log) { if (logEl.textContent !== data.log) {
@ -75,21 +88,23 @@
logEl.textContent = data.log || '(waiting for install to start)'; logEl.textContent = data.log || '(waiting for install to start)';
if (atBottom) logEl.scrollTop = logEl.scrollHeight; if (atBottom) logEl.scrollTop = logEl.scrollHeight;
} }
if (p.status === 'done') { if (p.status === 'done' || p.status === 'error') {
// Reload so the server-rendered Done state (with the // Reload so the server-rendered Done / error state (with
// Reboot button) replaces the running-state markup. // its Reboot or Try-again buttons) replaces the
// running-state markup.
window.location.reload(); window.location.reload();
return; return;
} }
if (p.status === 'error') { failures = 0;
bar.classList.add('progress-bar-error');
headingEl.textContent = HEADINGS.error;
ledeEl.textContent = LEDES.error;
return;
}
setTimeout(tick, 3000); setTimeout(tick, 3000);
}) })
.catch(function () { setTimeout(tick, 3000); }); .catch(function () {
failures += 1;
if (failures >= 2) {
phaseEl.textContent = 'Lost connection to the installer — retrying…';
}
setTimeout(tick, 3000);
});
} }
setTimeout(tick, 3000); setTimeout(tick, 3000);
})(); })();

View file

@ -5,27 +5,27 @@
{% block content %} {% block content %}
<h1>Confirm install</h1> <h1>Confirm install</h1>
<p class="lede">Review the settings below. Continuing will <strong>wipe <code>{{ settings.boot_drive }}</code></strong> and install Furtka.</p> <p class="lede">Check the settings below. Continuing will <strong>erase <code>{{ settings.boot_drive }}</code></strong> and install Furtka on it.</p>
<div class="alert alert-warn"> <div class="alert alert-warn" role="alert">
This is destructive. All existing data on the selected boot drive will be lost. Everything currently on this disk will be deleted and can't be recovered.
</div> </div>
<div class="summary"> <div class="summary">
<table> <table>
{% for k, v in settings.items() %} <tr><td>Server name</td><td>{{ settings.hostname }} <span class="hint">(http://{{ settings.hostname }}.local)</span></td></tr>
<tr> <tr><td>Username</td><td>{{ settings.username }}</td></tr>
<td>{{ k.replace('_', ' ') }}</td> <tr><td>Password</td><td>{{ settings.password }}</td></tr>
<td>{{ v }}</td> <tr><td>Language</td><td>{{ language_label }}</td></tr>
</tr> <tr><td>Disk</td><td>{{ settings.boot_drive }}{% if drive %} — {{ drive.size }} {{ drive.type_label }}{% endif %}</td></tr>
{% endfor %}
</table> </table>
</div> </div>
<form method="post" action="{{ url_for('install_run') }}"> <form method="post" action="{{ url_for('install_run') }}"
onsubmit="this.querySelector('button').disabled = true; this.querySelector('button').textContent = 'Starting…';">
<div class="actions"> <div class="actions">
<button type="submit" class="btn btn-danger">Install — wipe drive and proceed</button> <button type="submit" class="btn btn-danger">Erase disk and install</button>
<a href="{{ url_for('install_step_1') }}" class="btn btn-link">← Start over</a> <a href="{{ url_for('install_step_2') }}" class="btn btn-link">← Back</a>
</div> </div>
</form> </form>
{% endblock %} {% endblock %}

View file

@ -10,5 +10,7 @@
<p class="muted">If the installer does come back anyway, your BIOS is set to boot from USB before the disk. Press the one-time boot menu key at startup (often <kbd>F11</kbd>, <kbd>F12</kbd>, or <kbd>Esc</kbd> — it flashes briefly on screen) and pick the internal disk, or change the boot order in BIOS settings.</p> <p class="muted">If the installer does come back anyway, your BIOS is set to boot from USB before the disk. Press the one-time boot menu key at startup (often <kbd>F11</kbd>, <kbd>F12</kbd>, or <kbd>Esc</kbd> — it flashes briefly on screen) and pick the internal disk, or change the boot order in BIOS settings.</p>
<p>When the machine comes back up (~1 minute), open Furtka in your browser:</p> <p>When the machine comes back up (~1 minute), open Furtka in your browser:</p>
<p><a href="http://{{ hostname }}.local" class="btn btn-primary">http://{{ hostname }}.local</a></p> <p><a href="http://{{ hostname }}.local" class="btn btn-primary">http://{{ hostname }}.local</a></p>
<p class="muted">If that doesn't resolve, your network may not support mDNS — use the IP address shown on the machine's console instead.</p> <p class="muted">If that address doesn't open (some Android phones and older Windows PCs can't look up <code>.local</code> names),
use the IP address instead: it's shown on the machine's screen, or look for a device called
<strong>{{ hostname }}</strong> in your router's list of connected devices. The address may differ from the one the installer used.</p>
{% endblock %} {% endblock %}

View file

@ -4,11 +4,11 @@
{% block step_indicator %}<span class="step-indicator">Step 1 of 3</span>{% endblock %} {% block step_indicator %}<span class="step-indicator">Step 1 of 3</span>{% endblock %}
{% block content %} {% block content %}
<h1>Account &amp; hostname</h1> <h1>Name &amp; account</h1>
<p class="lede">Set the server name and your administrator account.</p> <p class="lede">Set the server name and your administrator account.</p>
{% if errors %} {% if errors %}
<div class="alert alert-error"> <div class="alert alert-error" role="alert">
<strong>Please fix the following:</strong> <strong>Please fix the following:</strong>
<ul> <ul>
{% for e in errors %}<li>{{ e }}</li>{% endfor %} {% for e in errors %}<li>{{ e }}</li>{% endfor %}
@ -18,19 +18,19 @@
<form method="post" action="{{ url_for('install_step_1') }}"> <form method="post" action="{{ url_for('install_step_1') }}">
<div class="field"> <div class="field">
<label for="hostname">Hostname</label> <label for="hostname">Server name</label>
<input type="text" id="hostname" name="hostname" required value="{{ values.hostname }}" autocomplete="off" /> <input type="text" id="hostname" name="hostname" required value="{{ values.hostname }}" autocomplete="off" autocapitalize="off" spellcheck="false" aria-describedby="hostname-hint" />
<span class="hint">The name your server shows up as on your home network. Lowercase letters, numbers, and dashes only.</span> <span class="hint" id="hostname-hint">You'll open your server at <strong>http://<span id="hostname-preview">{{ values.hostname or "furtka" }}</span>.local</strong>. Letters, numbers and dashes only.</span>
</div> </div>
<div class="field"> <div class="field">
<label for="username">Admin username</label> <label for="username">Admin username</label>
<input type="text" id="username" name="username" required value="{{ values.username }}" autocomplete="username" /> <input type="text" id="username" name="username" required value="{{ values.username }}" autocomplete="username" autocapitalize="off" spellcheck="false" aria-describedby="username-hint" />
<span class="hint">Your login name on the server. You'll use this to sign in after installation.</span> <span class="hint" id="username-hint">Your login name on the server. You'll use this to sign in after installation.</span>
</div> </div>
<div class="field"> <div class="field">
<label for="password">Password</label> <label for="password">Password</label>
<input type="password" id="password" name="password" required autocomplete="new-password" /> <input type="password" id="password" name="password" required minlength="8" autocomplete="new-password" aria-describedby="password-hint" />
<span class="hint">At least 8 characters.</span> <span class="hint" id="password-hint">At least 8 characters. This one password is used for the web login and for the server itself (SSH / console), so pick a good one.</span>
</div> </div>
<div class="field"> <div class="field">
<label for="password2">Confirm password</label> <label for="password2">Confirm password</label>
@ -38,15 +38,25 @@
</div> </div>
<div class="field"> <div class="field">
<label for="language">Language</label> <label for="language">Language</label>
<select id="language" name="language"> <select id="language" name="language" aria-describedby="language-hint">
{% for code, lang in languages.items() %} {% for code, lang in languages.items() %}
<option value="{{ code }}" {% if code == values.language %}selected{% endif %}>{{ lang.label }}</option> <option value="{{ code }}" {% if code == values.language %}selected{% endif %}>{{ lang.label }}</option>
{% endfor %} {% endfor %}
</select> </select>
<span class="hint" id="language-hint">Language and keyboard layout of the installed server. The Furtka pages themselves are in English for now.</span>
</div> </div>
<div class="actions"> <div class="actions">
<button type="submit" class="btn btn-primary">Next — pick boot drive</button> <button type="submit" class="btn btn-primary">Next — pick a disk</button>
</div> </div>
</form> </form>
<script>
(function () {
var input = document.getElementById('hostname');
var preview = document.getElementById('hostname-preview');
input.addEventListener('input', function () {
preview.textContent = input.value.trim().toLowerCase() || 'furtka';
});
})();
</script>
{% endblock %} {% endblock %}

View file

@ -1,39 +1,53 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Step 2 — Boot drive · Furtka Installer{% endblock %} {% block title %}Step 2 — Disk · Furtka Installer{% endblock %}
{% block step_indicator %}<span class="step-indicator">Step 2 of 3</span>{% endblock %} {% block step_indicator %}<span class="step-indicator">Step 2 of 3</span>{% endblock %}
{% block content %} {% block content %}
<h1>Boot drive</h1> <h1>Pick a disk</h1>
<p class="lede">Pick the disk Furtka will install onto. We pre-select the drive that looks fastest and healthiest.</p> <p class="lede">Choose the disk Furtka will be installed on. <strong>Everything on it will be erased.</strong> We pre-select the one that looks fastest and healthiest.</p>
{% if not drives %} {% set usable = drives | selectattr("usable") | list %}
<div class="alert alert-error"> {% set pick = selected if (selected and usable | selectattr("name", "equalto", selected) | list) else (usable[0].name if usable else "") %}
No physical disks detected. The live ISO can't see any install targets — check your BIOS / SATA controller.
{% if error %}
<div class="alert alert-error" role="alert">{{ error }}</div>
{% endif %}
{% if not usable %}
<div class="alert alert-error" role="alert">
{% if drives %}
None of the disks in this computer is big enough for Furtka (it needs at least 16 GB).
{% else %}
No disk found to install onto. Check that the disk is connected, and in the BIOS/UEFI settings
that the SATA mode is "AHCI" rather than "RAID" or "Intel RST" — Linux can't see disks in RAID mode.
{% endif %}
</div> </div>
{% endif %} {% endif %}
<form method="post" action="{{ url_for('install_step_2') }}"> <form method="post" action="{{ url_for('install_step_2') }}">
<div class="drive-list"> <div class="drive-list">
{% for d in drives %} {% for d in drives %}
<label class="drive"> <label class="drive{% if not d.usable %} drive-disabled{% endif %}">
<input type="radio" name="boot_drive" value="{{ d.name }}" <input type="radio" name="boot_drive" value="{{ d.name }}"
{% if (selected and d.name == selected) or (not selected and loop.first) %}checked{% endif %} /> {% if not d.usable %}disabled{% elif d.name == pick %}checked{% endif %} />
<span class="name">{{ d.name }}</span> <span class="name">{{ d.name }}</span>
{% if not selected and loop.first %} {% if not selected and d.usable and loop.first %}
<span class="badge-recommended">Recommended</span> <span class="badge-recommended">Recommended</span>
{% endif %} {% endif %}
<span class="meta"> <span class="meta">
<span class="chip">{{ d.size }}</span> <span class="chip">{{ d.size }}</span>
<span class="chip">{{ d.type_label }}</span> <span class="chip">{{ d.type_label }}</span>
<span class="chip">{{ d.health_label }}</span> {% if d.usable %}<span class="chip">{{ d.health_label }}</span>{% endif %}
</span> </span>
{% if d.reason %}<span class="drive-note">{{ d.reason }}</span>{% endif %}
{% if d.warning %}<span class="drive-note">{{ d.warning }}</span>{% endif %}
</label> </label>
{% endfor %} {% endfor %}
</div> </div>
<div class="actions"> <div class="actions">
<button type="submit" class="btn btn-primary" {% if not drives %}disabled{% endif %}>Next — review</button> <button type="submit" class="btn btn-primary" {% if not usable %}disabled{% endif %}>Next — review</button>
<a href="{{ url_for('install_step_1') }}" class="btn btn-link">← Back</a> <a href="{{ url_for('install_step_1') }}" class="btn btn-link">← Back</a>
</div> </div>
</form> </form>

View file

@ -42,6 +42,15 @@ This rsyncs `website/` to `/srv/furtka-site/` on the VM over SSH and runs
`hugo --minify` into `/var/www/furtka.org`. Same end state as the CI path, `hugo --minify` into `/var/www/furtka.org`. Same end state as the CI path,
just with an SSH hop. just with an SSH hop.
### Poll counter
`/api/poll/` is a tiny stdlib Python service (`ops/poll/pollsvc.py`,
SQLite, systemd `DynamicUser`) behind nginx on `127.0.0.1:8090`. Install or
update it on the VM with `sudo ops/poll/setup-poll.sh`; the nginx location
and rate limit come from `ops/nginx/` via `setup-vm.sh`. Devices are told
apart by a salted hash of IP + user agent — no cookie — and a repeat vote
from the same device changes the choice instead of adding one.
### First-time VM setup ### First-time VM setup
Only needed once, when provisioning a fresh forge-runner VM: Only needed once, when provisioning a fresh forge-runner VM:
@ -58,11 +67,18 @@ hugo.toml Hugo config (multilingual: en default, de)
content/ Markdown pages content/ Markdown pages
_index.md Home (EN) _index.md Home (EN)
_index.de.md Home (DE) _index.de.md Home (DE)
logo.md / .de.md Logo poll (/logo/, /de/logo/) — layout `poll`, votes via /api/poll/
layouts/ Custom inline theme — no external theme or framework layouts/ Custom inline theme — no external theme or framework
_default/ baseof, single, list _default/ baseof, single, list
partials/ head, header, footer, gate SVG, lang switcher partials/ head, header, footer, gate SVG, lang switcher, mark-m / mark-r (poll)
poll/single.html Logo poll page; strings come from the content front matter
index.html Home-only layout with editorial hero index.html Home-only layout with editorial hero
assets/css/main.css Stylesheet (fingerprinted + minified on build) assets/css/main.css Stylesheet (fingerprinted + minified on build)
assets/js/poll.js Vote script for /logo/ — plain fetch, no cookies, no storage
data/logo/{m,r}.json Generated: mark SVGs, lockups, 16 px icons, console banner per proposal
static/img/logo/ Generated: tiles, splash and social card per proposal
tools/logo-assets.py Generator for the two lines above (cairosvg + Pillow + fontTools);
run on the dev box, commit the output. Font: tools/FONT-PROVENANCE.md
static/favicon.svg Gate mark in crimson static/favicon.svg Gate mark in crimson
deploy.sh Manual rsync + remote Hugo build (over SSH, for off-CI pushes) deploy.sh Manual rsync + remote Hugo build (over SSH, for off-CI pushes)
deploy-ci.sh Local rsync + Hugo build — runs on forge-runner-01 from CI deploy-ci.sh Local rsync + Hugo build — runs on forge-runner-01 from CI

View file

@ -356,6 +356,214 @@ main.container {
.cta--primary { animation: none; } .cta--primary { animation: none; }
} }
.cta-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.9rem 1.1rem;
}
.cta--ghost {
color: var(--fg);
border: 1px solid var(--border);
background: var(--card-bg);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
}
.cta--ghost:hover {
border-color: var(--accent);
color: var(--accent);
transform: translateY(-2px);
}
/* ── How it works (home) ─────────────────────────────────── */
.how-steps {
list-style: none;
counter-reset: how;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
gap: 1rem;
}
.how-step {
counter-increment: how;
position: relative;
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 1rem;
padding: 1.4rem 1.5rem 1.5rem;
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
}
.how-step::before {
content: counter(how, decimal-leading-zero);
display: block;
font-family: var(--font-mono);
font-weight: 600;
font-size: 2.4rem;
line-height: 1;
letter-spacing: -0.04em;
margin-bottom: 0.9rem;
background-image: linear-gradient(135deg, var(--accent), var(--fg-muted));
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.how-step h3 { margin: 0 0 0.35rem; font-size: 1.15rem; letter-spacing: -0.01em; }
.how-step p { margin: 0; color: var(--fg-muted); font-size: 0.98rem; line-height: 1.5; }
.how-step strong { color: var(--fg); font-weight: 600; }
/* ── Download band (home, before the closer) ─────────────── */
.dl-band {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1.25rem 2rem;
margin-block: 4.5rem 0;
padding: 2rem;
border-radius: 1.25rem;
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border));
background:
radial-gradient(120% 140% at 0% 0%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 60%),
var(--card-bg);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
}
.dl-band h2 { margin: 0 0 0.3rem; font-size: 1.5rem; letter-spacing: -0.02em; }
.dl-band p { margin: 0; color: var(--fg-muted); max-width: 26rem; }
/* ── Download page ───────────────────────────────────────── */
.dl-hero { padding-block: 1rem 1rem; }
.dl-hero h1 {
font-size: clamp(2.4rem, 7vw, 4rem);
line-height: 1;
letter-spacing: -0.035em;
margin: 0 0 1rem;
}
.dl-hero .lede {
font-size: clamp(1.05rem, 1.6vw, 1.2rem);
color: var(--fg-muted);
max-width: 36rem;
margin: 0 0 2rem;
}
.dl-card {
padding: 1.75rem;
border-radius: 1.25rem;
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border));
background:
radial-gradient(120% 140% at 100% 0%, color-mix(in srgb, var(--accent) 12%, transparent), transparent 60%),
var(--card-bg);
}
.dl-card .cta svg { flex: none; }
.dl-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem 2rem;
margin: 1.5rem 0 0;
}
.dl-meta div { min-width: 0; }
.dl-meta dt {
font-size: 0.7rem;
font-weight: 500;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--fg-muted);
}
.dl-meta dd { margin: 0.15rem 0 0; overflow-wrap: anywhere; }
.mono { font-family: var(--font-mono); font-size: 0.92em; }
.dl-host { margin: 1.25rem 0 0; font-size: 0.85rem; color: var(--fg-muted); }
.dl-alpha {
margin: 1.5rem 0 0;
padding: 0.9rem 1.1rem;
border-left: 3px solid var(--accent);
background: var(--bg-subtle);
border-radius: 0 0.6rem 0.6rem 0;
font-size: 0.95rem;
max-width: 40rem;
}
.dl-section { margin-top: 3.5rem; }
.dl-needs { margin: 0; padding-left: 1.2rem; max-width: 40rem; }
.dl-needs li { margin-bottom: 0.55rem; }
.dl-needs li::marker { color: var(--accent); }
.dl-steps {
list-style: none;
counter-reset: dl;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 1rem;
}
.step-card {
counter-increment: dl;
display: grid;
grid-template-columns: 2.6rem 1fr;
gap: 0 1rem;
padding: 1.3rem 1.4rem;
border: 1px solid var(--card-border);
border-radius: 1rem;
background: var(--card-bg);
}
.step-card::before {
content: counter(dl);
grid-row: span 2;
display: grid;
place-items: center;
width: 2.6rem;
height: 2.6rem;
border-radius: 999px;
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
color: #fff;
font-weight: 700;
}
.step-card h2 { margin: 0.35rem 0 0.4rem; font-size: 1.15rem; letter-spacing: -0.01em; }
.step-body { min-width: 0; color: var(--fg-muted); }
.step-body p { margin: 0 0 0.7rem; }
.step-body p:last-child { margin: 0; }
.step-body strong { color: var(--fg); font-weight: 600; }
.step-body a, .dl-older a, .dl-alpha a { color: var(--accent); }
kbd {
font-family: var(--font-mono);
font-size: 0.85em;
padding: 0.05rem 0.4rem;
border: 1px solid var(--border);
border-bottom-width: 2px;
border-radius: 0.3rem;
background: var(--bg);
color: var(--fg);
}
.download pre {
margin: 0.4rem 0 0;
padding: 0.85rem 1rem;
border-radius: 0.6rem;
background: var(--bg-subtle);
border: 1px solid var(--border);
overflow-x: auto;
font-size: 0.85rem;
line-height: 1.55;
color: var(--fg);
}
.download code { font-family: var(--font-mono); }
.download :not(pre) > code {
padding: 0.05rem 0.3rem;
border-radius: 0.3rem;
background: var(--bg-subtle);
font-size: 0.9em;
}
.dl-hash code { overflow-wrap: anywhere; white-space: pre-wrap; }
.dl-cmds .c { color: var(--fg-muted); }
.dl-muted { color: var(--fg-muted); }
.dl-older { margin-top: 3rem; }
@media (max-width: 30rem) {
.dl-card, .dl-band { padding: 1.35rem; }
.step-card { grid-template-columns: 1fr; }
.step-card::before { grid-row: auto; margin-bottom: 0.6rem; }
}
/* ── Intro paragraph (home, between hero and feature grids) ─ */ /* ── Intro paragraph (home, between hero and feature grids) ─ */
.intro { .intro {
@ -439,3 +647,142 @@ main.container {
will-change: auto; will-change: auto;
} }
} }
/* ── Logo poll ───────────────────────────────────────────────── */
.poll-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr));
gap: 1.5rem;
margin-top: 2.5rem;
}
.poll-card {
display: flex;
flex-direction: column;
gap: 0.9rem;
padding: 1.4rem;
border: 1px solid var(--card-border);
border-radius: 1rem;
background: var(--card-bg);
transition: border-color 160ms, box-shadow 160ms;
}
.is-yours { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
.poll-card h2 { margin: 0; font-size: 1.25rem; letter-spacing: -0.01em; }
.poll-desc { margin: 0; color: var(--fg-muted); font-size: 0.95rem; }
.poll-art { display: grid; place-items: center; padding: 2.2rem 1rem; border-radius: 0.75rem; }
.poll-art--m { background: var(--bg-subtle); }
.poll-art--r { background: #151515; }
.poll-art .pm { height: 9rem; width: auto; }
.pm { display: block; }
.poll-btn {
margin-top: auto;
padding: 0.85rem 1.2rem;
border: 1px solid var(--accent);
border-radius: 0.6rem;
background: var(--accent);
color: #fff;
font: inherit;
font-weight: 600;
cursor: pointer;
transition: background 140ms, transform 140ms;
}
.poll-btn:hover:not(:disabled) { background: var(--accent-hover); transform: translateY(-1px); }
.poll-btn:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.poll-btn:disabled { cursor: default; opacity: 0.85; }
.is-yours .poll-btn { background: transparent; color: var(--accent); }
.poll-bar { height: 0.45rem; border-radius: 999px; background: var(--bg-subtle); overflow: hidden; display: none; }
.poll-bar i { display: block; height: 100%; width: 0; background: var(--accent); transition: width 500ms ease; }
.poll--voted .poll-bar { display: block; }
.poll-bar--final { display: block; }
.poll-result {
margin: 1.5rem 0 0;
padding: 1rem 1.2rem;
border-left: 3px solid var(--accent);
background: var(--bg-subtle);
border-radius: 0 0.6rem 0.6rem 0;
max-width: 40rem;
}
.poll-result strong { color: var(--accent); }
.poll-count { margin: 0; min-height: 1.4em; font-size: 0.85rem; color: var(--fg-muted); font-variant-numeric: tabular-nums; }
.poll-status { margin: 1.25rem 0 0; min-height: 1.5em; color: var(--fg-muted); }
.poll-note { margin-top: 1rem; max-width: var(--measure); font-size: 0.85rem; color: var(--fg-muted); }
.poll-note a { color: var(--accent); }
/* showcase */
.show { display: flex; flex-direction: column; gap: 3rem; margin-top: 4rem; }
.show-intro { margin: 0; }
.show-row { display: flex; flex-direction: column; gap: 0.6rem; }
.show-h { margin: 0; font-size: 1.35rem; letter-spacing: -0.01em; }
.show-p { margin: 0 0 0.6rem; max-width: var(--measure); color: var(--fg-muted); font-size: 0.95rem; }
.show-pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr)); gap: 1.25rem; }
.show-cell {
display: flex; flex-direction: column; gap: 0.9rem;
padding: 1.1rem; border: 1px solid var(--card-border); border-radius: 0.9rem; background: var(--card-bg);
}
.show-cell--m { color: var(--accent); }
.show-cell--r { color: #af6428; }
.show-tag { font-size: 0.7rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--fg-muted); }
.sizes { display: flex; align-items: flex-end; gap: 1.1rem; flex-wrap: wrap; }
.sizes figure { margin: 0; display: flex; flex-direction: column; align-items: center; gap: 0.35rem; }
.sizes figcaption { font-family: var(--font-mono); font-size: 0.65rem; color: var(--fg-muted); }
.sz { display: block; }
.sz .pm { width: auto; }
.sz-96 .pm { height: 96px; } .sz-48 .pm { height: 48px; } .sz-32 .pm { height: 32px; } .sz-24 .pm { height: 24px; }
.px { image-rendering: pixelated; }
.lock { display: grid; place-items: center; padding: 1rem; border-radius: 0.6rem; background: var(--bg-subtle); }
.lock-h .pm { height: 3rem; width: auto; max-width: 100%; }
.lock-s .pm { height: 7rem; width: auto; max-width: 100%; }
.mock { border: 1px solid var(--border); border-radius: 0.7rem; overflow: hidden; background: var(--bg); color: var(--fg); font-size: 0.8rem; }
.tabbar { background: var(--bg-subtle); padding: 0.55rem 0.65rem; }
.tab { display: inline-flex; align-items: center; gap: 0.45rem; padding: 0.3rem 0.65rem; border: 1px solid var(--border); border-radius: 0.4rem; background: var(--bg); color: var(--fg-muted); }
.urlbar { padding: 0.45rem 0.75rem; border-top: 1px solid var(--border); font-family: var(--font-mono); font-size: 0.72rem; color: var(--fg-muted); }
.uihead { display: flex; align-items: center; gap: 0.5rem; padding: 0.8rem 0.9rem; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.uihead-mark { display: inline-flex; color: inherit; }
.show-cell--m .uihead-mark { color: var(--accent); }
.show-cell--r .uihead-mark { color: #af6428; }
.uihead-mark .pm { height: 1.25rem; width: auto; }
.uihead-name { font-weight: 600; letter-spacing: 0.13em; text-transform: uppercase; font-size: 0.72rem; color: var(--fg); }
.uihead-nav { margin-left: auto; display: flex; gap: 0.7rem; color: var(--fg-muted); }
.uibody { padding: 0.9rem; display: flex; flex-direction: column; gap: 0.45rem; }
.uibody i { display: block; height: 0.45rem; border-radius: 3px; background: var(--bg-subtle); }
.uibody i.s { width: 45%; }
.tiles { display: flex; gap: 1rem; justify-content: center; padding: 1.2rem; border-radius: 0.6rem; background: var(--bg-subtle); }
.tiles img { width: 6rem; height: 6rem; }
.term {
margin: 0; padding: 0.9rem 1rem; border-radius: 0.6rem; overflow-x: auto;
background: #100f0c; color: #d8d2c2; font-family: var(--font-mono); font-size: 0.55rem; line-height: 1;
-webkit-text-size-adjust: none; text-size-adjust: none;
}
.term i, .term b { font-style: normal; font-weight: 400; color: inherit; }
.show-cell--m .term i { color: #ff6b56; } .show-cell--m .term b { color: #ff6b56; }
.show-cell--r .term i { color: #c9803f; } .show-cell--r .term b { color: #c9803f; }
.shot { display: block; width: 100%; height: auto; border-radius: 0.6rem; }
.grounds { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.6rem; }
.ground { display: grid; place-items: center; padding: 1.2rem; border-radius: 0.6rem; border: 1px solid var(--border); }
.ground--fg { background: var(--bg-subtle); color: var(--fg); }
.ground .pm { height: 3.2rem; width: auto; }
.poll-final { margin-top: 3.5rem; display: flex; flex-direction: column; gap: 1rem; }
.poll-final-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: 1.25rem; }
.poll-final-card {
display: flex; flex-direction: column; gap: 0.8rem; align-items: stretch;
padding: 1.1rem; border: 1px solid var(--card-border); border-radius: 0.9rem; background: var(--card-bg);
}
.poll-final-mark { display: grid; place-items: center; padding: 0.8rem; }
.poll-final-mark .pm { height: 3.5rem; width: auto; }
.home-poll {
display: inline-flex; align-items: center; gap: 0.6rem; margin-top: 1.4rem;
padding: 0.55rem 0.95rem; border: 1px solid var(--border); border-radius: 999px;
background: var(--card-bg); color: var(--fg); text-decoration: none; font-size: 0.92rem;
}
.home-poll:hover { border-color: var(--accent); }
.home-poll .pm { height: 1.1rem; width: auto; color: var(--accent); }
.home-poll .pm + .pm { color: #af6428; }
/* Random left/right per visit — data-logo-order is set in head.html before first paint.
Every pair container is a grid/flex, so `order` swaps the columns without touching the DOM. */
[data-logo-order="rm"] .poll-card[data-choice="m"],
[data-logo-order="rm"] .show-cell.show-cell--m,
[data-logo-order="rm"] .poll-final-card[data-choice="m"],
[data-logo-order="mr"] .poll-card[data-choice="r"],
[data-logo-order="mr"] .show-cell.show-cell--r,
[data-logo-order="mr"] .poll-final-card[data-choice="r"] { order: 1; }
@media (prefers-reduced-motion: reduce) { .poll-bar i, .poll-btn { transition: none; } }

59
website/assets/js/poll.js Normal file
View file

@ -0,0 +1,59 @@
// furtka.org logo poll — no cookies, no storage. The server tells us
// what this device already chose (salted hash of IP + user agent).
(function () {
var root = document.querySelector('.poll');
if (!root || !window.fetch) return;
var api = root.dataset.api;
var status = root.querySelector('.poll-status');
var t = status.dataset;
var buttons = root.querySelectorAll('[data-vote]');
var busy = false;
function pct(n, total) { return total ? Math.round((n / total) * 100) : 0; }
function each(sel, fn) { Array.prototype.forEach.call(root.querySelectorAll(sel), fn); }
function render(d) {
var total = d.total || 0;
root.classList.toggle('poll--voted', !!d.yours);
Object.keys(d.counts).forEach(function (k) {
var n = d.counts[k], p = pct(n, total), mine = d.yours === k;
each('[data-choice="' + k + '"]', function (card) {
card.classList.toggle('is-yours', mine);
var bar = card.querySelector('.poll-bar i'); if (bar) bar.style.width = p + '%';
var c = card.querySelector('[data-count]'); if (c) c.textContent = d.yours ? p + ' % · ' + n : '';
});
each('[data-vote="' + k + '"]', function (b) {
b.disabled = mine;
b.textContent = mine ? t.tVoted : (d.yours ? t.tChange : b.dataset.label);
});
});
status.textContent = d.yours ? t.tTotal.replace('{n}', total) : '';
}
function load() {
fetch(api, { headers: { Accept: 'application/json' } })
.then(function (r) { if (!r.ok) throw r; return r.json(); })
.then(render)
.catch(function () { status.textContent = t.tError; });
}
Array.prototype.forEach.call(buttons, function (b) {
b.dataset.label = b.textContent;
b.addEventListener('click', function () {
if (busy) return;
busy = true; b.disabled = true;
fetch(api, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ choice: b.dataset.vote })
})
.then(function (r) { if (!r.ok) throw r; return r.json(); })
.then(function (d) { render(d); status.scrollIntoView({ block: 'nearest' }); })
.catch(function () { status.textContent = t.tError; b.disabled = false; })
.then(function () { busy = false; });
});
});
status.textContent = '';
load();
})();

View file

@ -1,7 +1,11 @@
--- ---
title: "Furtka" title: "Furtka"
description: "Offenes Heimserver-Betriebssystem — einfach genug für alle." description: "Offenes Heimserver-Betriebssystem — einfach genug für alle."
status: "<span class=\"mono\">26.18-alpha</span> — in Arbeit" status_text: "in Arbeit"
cta_download: "ISO herunterladen"
cta_source: "Quellcode"
poll_link: "/de/logo/"
poll_text: "Abgestimmt — das ist unser neues Logo"
# features_today / features_next müssen index-parallel zu content/_index.md bleiben. # features_today / features_next müssen index-parallel zu content/_index.md bleiben.
intro: | intro: |
**Furtka** ist ein offenes Heimserver-Betriebssystem. **Furtka** ist ein offenes Heimserver-Betriebssystem.
@ -10,6 +14,16 @@ intro: |
Apps, eigenem Namen im Netz, eigenen Daten. Apps, eigenem Namen im Netz, eigenen Daten.
Das Ziel ist einfach: **dein Vater soll das einrichten können.** Das Ziel ist einfach: **dein Vater soll das einrichten können.**
steps_label: "So funktioniert's"
steps:
- title: "Herunterladen"
body: "Installer holen und auf einen USB-Stick schreiben."
- title: "Installieren"
body: "Computer vom Stick starten und dem Assistenten folgen — im Browser auf Handy oder Laptop."
- title: "Loslegen"
body: "**http://furtka.local** öffnen und Apps mit einem Klick installieren."
band_title: "Lust, es auszuprobieren?"
band_text: "Kostenlos und quelloffen (AGPL-3.0). Läuft auf 64-Bit-PCs, auch auf alten."
features_today_label: "Was heute schon geht" features_today_label: "Was heute schon geht"
features_today: features_today:
- "Vom USB-Stick booten und Furtka auf die Festplatte einrichten" - "Vom USB-Stick booten und Furtka auf die Festplatte einrichten"
@ -19,11 +33,15 @@ features_today:
- "Apps mit einem Klick installieren und entfernen" - "Apps mit einem Klick installieren und entfernen"
- "Eine installierte App mit einem Klick aktualisieren (holt das neueste Container-Image)" - "Eine installierte App mit einem Klick aktualisieren (holt das neueste Container-Image)"
- "Furtka selbst mit einem Klick aktualisieren — keine Neuinstallation mehr für neue Features" - "Furtka selbst mit einem Klick aktualisieren — keine Neuinstallation mehr für neue Features"
- "Die Bedienseite fragt nach einem Passwort — niemand im WLAN kommt einfach so rein"
- "Neue Apps kommen aus einem Online-Katalog, ohne auf ein Furtka-Update zu warten"
- "Apps können auf anderen Apps aufbauen — was eine braucht, wird mit installiert"
- "Verschlüsselte Verbindung im Heimnetz, in den Einstellungen einschaltbar"
features_next_label: "Was als Nächstes kommt" features_next_label: "Was als Nächstes kommt"
features_next: features_next:
- "Apps für Fotos, Dateien, Smarthome, Spiele-Streaming und Medien" - "Apps für Fotos, Dateien, Smarthome, Spiele-Streaming und Medien"
- "Einfachere Sprache im Einrichtungs-Assistenten" - "Einfachere Sprache im Einrichtungs-Assistenten"
- "Sichere Verbindung im Heimnetz (ohne Warnmeldung im Browser)" - "Den Server auch von unterwegs erreichen"
- "Mehrere Server zusammenschalten" - "Mehrere Server zusammenschalten"
--- ---

View file

@ -1,7 +1,11 @@
--- ---
title: "Furtka" title: "Furtka"
description: "Open-source home server OS — simple enough for everyone." description: "Open-source home server OS — simple enough for everyone."
status: "<span class=\"mono\">26.18-alpha</span> — work in progress" status_text: "work in progress"
cta_download: "Download ISO"
cta_source: "Source code"
poll_link: "/logo/"
poll_text: "The vote is in — meet our new logo"
# Keep features_today / features_next index-aligned with content/_index.de.md. # Keep features_today / features_next index-aligned with content/_index.de.md.
intro: | intro: |
**Furtka** is an open-source home server OS. **Furtka** is an open-source home server OS.
@ -10,6 +14,16 @@ intro: |
your own name on the network, your own data. your own name on the network, your own data.
The goal is simple: **your dad should be able to set this up.** The goal is simple: **your dad should be able to set this up.**
steps_label: "How it works"
steps:
- title: "Download"
body: "Get the installer and write it to a USB stick."
- title: "Install"
body: "Start the computer from the stick and follow the wizard — in the browser on your phone or laptop."
- title: "Use"
body: "Open **http://furtka.local** and install apps with one click."
band_title: "Ready to try it?"
band_text: "Free and open source (AGPL-3.0). Runs on 64-bit PCs, old ones included."
features_today_label: "What works today" features_today_label: "What works today"
features_today: features_today:
- "Boot from USB stick and install Furtka onto the hard drive" - "Boot from USB stick and install Furtka onto the hard drive"
@ -19,11 +33,15 @@ features_today:
- "Install and remove apps with one click" - "Install and remove apps with one click"
- "Update an installed app with one click (pulls the newest container image)" - "Update an installed app with one click (pulls the newest container image)"
- "Update Furtka itself with one click — no reinstalling for new features" - "Update Furtka itself with one click — no reinstalling for new features"
- "The control page asks for a password — nobody on the Wi-Fi can just walk in"
- "New apps arrive from an online catalog, without waiting for a Furtka update"
- "Apps can build on other apps — what one needs gets installed alongside it"
- "Encrypted connection on your home network, switched on in settings"
features_next_label: "What's coming next" features_next_label: "What's coming next"
features_next: features_next:
- "Apps for photos, files, smart home, game streaming and media" - "Apps for photos, files, smart home, game streaming and media"
- "Plainer language in the setup wizard" - "Plainer language in the setup wizard"
- "Secure connection on your home network (no browser warning)" - "Reaching your server from outside the house"
- "Linking several servers together" - "Linking several servers together"
--- ---

View file

@ -11,7 +11,8 @@ Diese Website setzt **keine Cookies**, lädt **keine Schriften oder
Skripte von Drittanbietern**, bindet **keine Analyse- oder Skripte von Drittanbietern**, bindet **keine Analyse- oder
Tracking-Dienste** ein und enthält **keine externen Einbettungen** Tracking-Dienste** ein und enthält **keine externen Einbettungen**
(YouTube, Maps, Social-Media-Buttons, …). Technisch anfallend sind (YouTube, Maps, Social-Media-Buttons, …). Technisch anfallend sind
ausschließlich kurzfristige Server-Zugriffsprotokolle. ausschließlich kurzfristige Server-Zugriffsprotokolle — und, solange die
[Logo-Abstimmung](/de/logo/) läuft, ein gesalzener Hash pro Stimme (siehe unten).
### Verantwortlicher ### Verantwortlicher
@ -43,6 +44,22 @@ an Betrieb und Sicherheit).
Infrastruktur; es gibt keinen externen Auftragsverarbeiter. Infrastruktur; es gibt keinen externen Auftragsverarbeiter.
**Drittlandübermittlung:** keine. **Drittlandübermittlung:** keine.
### Logo-Abstimmung
Auf der Seite [/de/logo/](/de/logo/) können Sie zwischen zwei
Logo-Vorschlägen abstimmen. Damit jedes Gerät ohne Cookie einmal gezählt
wird, speichert der Abstimmungsserver einen **gesalzenen SHA-256-Hash aus
Ihrer IP-Adresse und Browser-Kennung** zusammen mit Ihrer Wahl und dem
Zeitpunkt der Stimmabgabe. Der Hash lässt sich nicht in die Adresse
zurückrechnen; das Salt verlässt den Server nicht.
**Zweck:** Zählung je Gerät einmal.
**Rechtsgrundlage:** Art. 6 Abs. 1 lit. f DSGVO — berechtigtes Interesse
an einer ehrlichen Auszählung; die Teilnahme ist freiwillig.
**Speicherdauer:** bis zum Ende der Abstimmung, danach wird die Datenbank
gelöscht.
**Empfänger:** keine.
### Cookies und Tracking ### Cookies und Tracking
Keine. Es werden keine Cookies gesetzt, kein LocalStorage oder Keine. Es werden keine Cookies gesetzt, kein LocalStorage oder
@ -75,5 +92,5 @@ Website: <https://www.datenschutz.rlp.de>
### Stand ### Stand
Diese Erklärung ist aktuell gültig und wurde zuletzt am 18.04.2026 Diese Erklärung ist aktuell gültig und wurde zuletzt am 26.08.2026
aktualisiert. aktualisiert.

View file

@ -11,7 +11,8 @@ sitemap:
This website sets **no cookies**, loads **no third-party fonts or This website sets **no cookies**, loads **no third-party fonts or
scripts**, embeds **no analytics or tracking services**, and contains scripts**, embeds **no analytics or tracking services**, and contains
**no external embeds** (YouTube, Maps, social buttons, …). The only **no external embeds** (YouTube, Maps, social buttons, …). The only
technical data collected is short-lived server access logs. technical data collected is short-lived server access logs — plus, while
the [logo poll](/logo/) is running, one salted hash per vote (see below).
### Controller ### Controller
@ -43,6 +44,20 @@ and security.
external processor is involved. external processor is involved.
**Transfers outside the EU/EEA:** none. **Transfers outside the EU/EEA:** none.
### Logo poll
The page [/logo/](/logo/) lets you vote between two logo proposals. So
that each device is counted once without a cookie, the poll server stores
a **salted SHA-256 hash of your IP address and browser identification**
together with your choice and the time of the vote. The hash cannot be
turned back into the address; the salt never leaves the server.
**Purpose:** counting votes once per device.
**Legal basis:** Art. 6(1)(f) GDPR — legitimate interest in an honest
tally; voting is voluntary.
**Retention:** until the poll is closed, then the database is deleted.
**Recipients:** none.
### Cookies and tracking ### Cookies and tracking
None. No cookies are set, no localStorage or sessionStorage is used, and None. No cookies are set, no localStorage or sessionStorage is used, and
@ -75,6 +90,6 @@ Website: <https://www.datenschutz.rlp.de>
### Last updated ### Last updated
This statement was last updated on 2026-04-18. This statement was last updated on 2026-08-26.
The German version of this privacy statement is the legally binding one. The German version of this privacy statement is the legally binding one.

View file

@ -0,0 +1,40 @@
---
title: "Furtka herunterladen"
description: "Der Furtka-Installer als ISO-Datei: auf einen USB-Stick schreiben, den Computer davon starten und den Heimserver im Browser einrichten."
translationKey: "download"
url: /de/download/
type: download
button: "ISO herunterladen"
released: "Veröffentlicht"
size_label: "Größe"
alpha: "Furtka ist **Alpha-Software**: Dinge ändern sich und manches geht kaputt. Installiere es auf einem Computer, dessen Festplatte du nicht brauchst, und sichere alles Wichtige."
host_note: "Die Datei kommt von unserem Forgejo-Server (forgejo.sourcegate.online)."
need_label: "Was du brauchst"
needs:
- "Einen **64-Bit-PC** (x86-64) — ein alter Laptop oder Desktop reicht. UEFI- und ältere BIOS-Rechner funktionieren beide."
- "Eine Festplatte mit **mindestens 16 GB** für Furtka. **Sie wird komplett gelöscht.**"
- "Einen **USB-Stick** mit mindestens 2 GB (wird ebenfalls gelöscht)."
- "Ein **Netzwerkkabel** mit Internetzugang — der Installer lädt Pakete herunter, und WLAN wird nicht automatisch eingerichtet."
- "Ein **zweites Gerät mit Browser** (Handy oder Laptop) im selben Netzwerk."
steps_label: "So installierst du Furtka"
steps:
- title: "ISO auf einen USB-Stick schreiben"
body: |
Nimm [balenaEtcher](https://etcher.balena.io/) (Windows, macOS, Linux) oder [Rufus](https://rufus.ie/) unter Windows — wenn Rufus fragt, wähle den **DD-Abbild-Modus**. Unter Linux geht auch `dd` (ersetze `/dev/sdX` durch deinen Stick — lieber zweimal prüfen):
```
sudo dd if={iso} of=/dev/sdX bs=4M status=progress oflag=sync
```
- title: "Computer vom Stick starten"
body: "Stick und Netzwerkkabel einstecken, Computer einschalten und die Taste fürs Bootmenü drücken (oft <kbd>F11</kbd>, <kbd>F12</kbd> oder <kbd>Esc</kbd> — sie wird kurz am Bildschirm angezeigt). Den USB-Stick auswählen. Startet der Computer nicht davon, schalte **Secure Boot** in den Firmware-Einstellungen aus."
- title: "Installer von einem anderen Gerät öffnen"
body: "Nach kurzer Zeit zeigt der Bildschirm eine Adresse an — meist **http://proksi.local:5000**, dazu die IP-Adresse als Ausweichweg. Öffne sie im Browser auf Handy oder Laptop."
- title: "Durch den Assistenten klicken"
body: "Drei Seiten: ein Name für deinen Server mit Benutzername und Passwort, die Festplatte und eine letzte Kontrolle. Danach lädt der Installer alles herunter und richtet es ein."
- title: "Stick raus, neu starten, fertig"
body: "Wenn der Assistent es sagt, den USB-Stick abziehen und neu starten. Dein Server ist dann unter **http://&lt;name&gt;.local** erreichbar — zum Beispiel http://furtka.local."
verify_label: "Download prüfen (optional)"
verify_intro: "Vergleiche die Prüfsumme der heruntergeladenen Datei mit dieser:"
verify_missing: "Für die ISO dieses Releases ist noch keine Prüfsumme veröffentlicht — künftige Releases bringen eine mit."
older: "Alle Releases und Änderungen"
---

View file

@ -0,0 +1,40 @@
---
title: "Download Furtka"
description: "The Furtka installer as an ISO file: write it to a USB stick, start your computer from it, and set up your home server in the browser."
translationKey: "download"
url: /download/
type: download
button: "Download ISO"
released: "Released"
size_label: "Size"
alpha: "Furtka is **alpha software**: things change and some will break. Install it on a computer whose disk you don't need, and keep a backup of anything important."
host_note: "The file is served from our Forgejo server (forgejo.sourcegate.online)."
need_label: "What you need"
needs:
- "A **64-bit PC** (x86-64) — an old laptop or desktop is fine. Both UEFI and older BIOS computers work."
- "A disk of **at least 16 GB** for Furtka. **It will be erased completely.**"
- "A **USB stick** with at least 2 GB (also erased)."
- "A **network cable** with internet access — the installer downloads packages, and Wi-Fi isn't set up automatically."
- "A **second device with a browser** (phone or laptop) on the same network."
steps_label: "How to install"
steps:
- title: "Write the ISO to a USB stick"
body: |
Use [balenaEtcher](https://etcher.balena.io/) (Windows, macOS, Linux) or [Rufus](https://rufus.ie/) on Windows — if Rufus asks, pick **DD image mode**. On Linux you can also use `dd` (replace `/dev/sdX` with your stick — double-check it):
```
sudo dd if={iso} of=/dev/sdX bs=4M status=progress oflag=sync
```
- title: "Start the computer from the stick"
body: "Plug in the stick and the network cable, switch the computer on and press the one-time boot menu key (often <kbd>F11</kbd>, <kbd>F12</kbd> or <kbd>Esc</kbd> — it flashes briefly on screen). Pick the USB stick. If the computer refuses to start from it, switch off **Secure Boot** in the firmware settings."
- title: "Open the installer from another device"
body: "After a short while the screen shows an address — usually **http://proksi.local:5000**, plus the IP address as a fallback. Open it in a browser on your phone or laptop."
- title: "Click through the wizard"
body: "Three pages: a name for your server with your username and password, the disk to install on, and a last check. Then the installer downloads and sets everything up."
- title: "Pull the stick, restart, done"
body: "When the wizard says so, remove the USB stick and restart. Your server comes up at **http://&lt;name&gt;.local** — for example http://furtka.local."
verify_label: "Check the download (optional)"
verify_intro: "Compare the checksum of the file you downloaded with this one:"
verify_missing: "This release has no published checksum for the ISO yet — future releases include one."
older: "All releases and changelogs"
---

View file

@ -0,0 +1,58 @@
---
title: "Unser neues Logo"
description: "Zwei Vorschläge für das Furtka-Zeichen, und ihr habt gewählt. So ist die Abstimmung ausgegangen — und so sähe jedes Zeichen überall aus, wo es funktionieren müsste."
closed: true
result: "Die Abstimmung ist beendet — **M hat mit 29 von 42 Stimmen gewonnen** (R: 13). Danke an alle, die abgestimmt haben! M ist jetzt das Furtka-Logo."
final:
m: 29
r: 13
translationKey: "logo"
layout: single
type: poll
poll: logo
image: /img/logo/vote-card.png
sitemap:
priority: 0.5
m:
short: "M"
title: "M — das f im Tor"
text: "Ein kleines f, aus einem Rundbogentor ausgestanzt. Der Stamm ist der Torpfosten, die zwei Balken sind die Querriegel."
r:
short: "R"
title: "R — Fenster, Rad und F"
text: "Ein Bogenfenster mit einem Speichenrad dahinter; in der oberen rechten Scheibe wird die Speiche zum F."
show:
intro: "Beide Zeichen überall dort, wo sie hätten funktionieren müssen."
sizes: "Groß und klein"
sizes_text: "Vom Poster bis zum 16-Pixel-Symbol im Browser-Tab. Unter 32 Pixeln wechseln beide auf eine vereinfachte Zeichnung — M verliert nichts, R behält Fenster, Kreuz und Nabe."
name: "Mit dem Namen"
name_text: "Nebeneinander und gestapelt. Die Wortmarke ist bei beiden dieselbe, also entscheidet nur das Zeichen."
browser: "Im Browser"
browser_text: "Das Tab-Symbol in echter Größe und die Kopfzeile von furtka.org."
nav1: "Doku"
nav2: "Blog"
phone: "Auf dem Handy"
phone_text: "Startbildschirm-Symbol — eckig wie auf Android, rund wie manche Launcher es beschneiden."
box: "Auf dem Server"
box_text: "Der Text-Willkommensbildschirm, den der Server am Monitor zeigt, und der Startbildschirm beim Booten."
shared: "Geteilt"
shared_text: "Die Vorschaukarte, die erscheint, wenn jemand einen Link zu furtka.org postet."
colour: "In Farbe"
colour_text: "Auf hell, auf dunkel, auf der eigenen Farbe und in normaler Textfarbe — jedes Zeichen ist eine Form und nimmt die Farbe an, die es umgibt."
final: "Genug gesehen?"
labels:
vote: "Dieses hier"
voted: "Deine Wahl"
change: "Zu diesem wechseln"
total: "Bisher {n} Stimmen. Danke!"
error: "Die Abstimmung ist gerade nicht erreichbar — bitte gleich noch einmal versuchen."
nojs: "Zum Abstimmen wird JavaScript benötigt."
note: "Keine Cookies, kein Konto. Damit jedes Gerät einmal zählt, speichert der Server einen gesalzenen Hash aus IP-Adresse und Browser — nicht die Adresse selbst. Details in der <a href=\"/de/datenschutz/\">Datenschutzerklärung</a>."
---
*Furtka* ist polnisch für die kleine Gartenpforte — die Tür im Zaun, durch
die man hineinkommt, ohne das große Tor aufzumachen. Beide Zeichen spielen
damit: das eine zeichnet den Buchstaben ins Tor, das andere schaut durchs
Tor auf ein Rad. Unten stehen beide mit dem Endergebnis — und jedes Zeichen
überall dort, wo es hätte funktionieren müssen, vom Poster bis zum winzigen
Symbol im Browser-Tab.

58
website/content/logo.md Normal file
View file

@ -0,0 +1,58 @@
---
title: "Our new logo"
description: "Two proposals for the Furtka mark, and you picked one. Here is how the vote ended — and each mark everywhere it would have had to work."
closed: true
result: "The vote is closed — **M won with 29 of 42 votes** (R: 13). Thank you to everyone who voted! M is now the Furtka logo."
final:
m: 29
r: 13
translationKey: "logo"
url: /logo/
layout: single
type: poll
poll: logo
image: /img/logo/vote-card.png
sitemap:
priority: 0.5
m:
short: "M"
title: "M — the f in the gate"
text: "A lowercase f cut out of an arched gate. The stem is the gate post, the two rails are the crossbars."
r:
short: "R"
title: "R — window, wheel and F"
text: "An arched window with a wheel of spokes behind it; in the top-right pane the spoke becomes an F."
show:
intro: "Both marks everywhere they would have had to work."
sizes: "Big and small"
sizes_text: "From a poster to the 16-pixel icon in a browser tab. Below 32 pixels both marks switch to a simplified drawing — M drops nothing, R keeps the window, the cross and the hub."
name: "With the name"
name_text: "Side by side and stacked. The wordmark is the same for both, so only the mark decides."
browser: "In the browser"
browser_text: "The tab icon at its real size, and the site header of furtka.org."
nav1: "Docs"
nav2: "Blog"
phone: "On the phone"
phone_text: "Home-screen icon — square as on Android, round as some launchers cut it."
box: "On the box"
box_text: "The text-mode welcome screen your server shows on a monitor, and the boot splash."
shared: "Shared"
shared_text: "The preview card that appears when someone posts a link to furtka.org."
colour: "In colour"
colour_text: "On light, on dark, on its own colour, and in plain text colour — each mark is one shape and takes whatever colour is around it."
final: "Seen enough?"
labels:
vote: "This one"
voted: "Your pick"
change: "Switch to this one"
total: "{n} votes so far. Thank you!"
error: "Couldn't reach the poll right now — please try again in a moment."
nojs: "Voting needs JavaScript."
note: "No cookies, no account. To count each device once, the server keeps a salted hash of your IP address and browser — not the address itself. Details in the <a href=\"/privacy/\">privacy notice</a>."
---
*Furtka* is Polish for a small garden gate — the little door in the fence
that lets you in without opening the whole thing. Both marks play with that:
one draws the letter into the gate, the other looks through the gate at a
wheel. Below are both, with the final count — and each mark everywhere it
would have had to work, from a poster to the tiny icon in a browser tab.

12
website/data/logo/m.json Normal file
View file

@ -0,0 +1,12 @@
{
"display": "<svg class=\"pm\" viewBox=\"0 0 64 64\" aria-hidden=\"true\" focusable=\"false\"><defs><linearGradient id=\"gmd\" x1=\".15\" y1=\"0\" x2=\".7\" y2=\"1\"><stop offset=\"0\" stop-color=\"#d95a45\"/><stop offset=\".55\" stop-color=\"#c03a28\"/><stop offset=\"1\" stop-color=\"#8a2618\"/></linearGradient><clipPath id=\"cmd\" clip-rule=\"evenodd\"><path d=\"M6 60V28a26 26 0 0 1 52 0v32Z M20.0 60V26.5A10.5 10.5 0 0 1 30.5 16H45.0v8.6H32.5a3.5 3.5 0 0 0-3.5 3.5V32h16v8.6H29.0V60Z\"/></clipPath></defs><path fill=\"url(#gmd)\" fill-rule=\"evenodd\" d=\"M6 60V28a26 26 0 0 1 52 0v32Z M20.0 60V26.5A10.5 10.5 0 0 1 30.5 16H45.0v8.6H32.5a3.5 3.5 0 0 0-3.5 3.5V32h16v8.6H29.0V60Z\"/><g clip-path=\"url(#cmd)\"><path fill=\"#fff\" opacity=\".14\" d=\"M6 28a26 26 0 0 1 52 0v4.5a26 26 0 0 0-52 0Z\"/></g></svg>",
"flat": "<svg class=\"pm\" viewBox=\"0 0 64 64\" aria-hidden=\"true\" focusable=\"false\"><path fill=\"currentColor\" fill-rule=\"evenodd\" d=\"M6 60V28a26 26 0 0 1 52 0v32Z M20.0 60V26.5A10.5 10.5 0 0 1 30.5 16H45.0v8.6H32.5a3.5 3.5 0 0 0-3.5 3.5V32h16v8.6H29.0V60Z\"/></svg>",
"min": "<svg class=\"pm\" viewBox=\"0 0 64 64\" aria-hidden=\"true\" focusable=\"false\"><path fill=\"currentColor\" fill-rule=\"evenodd\" d=\"M5 60V27a27 27 0 0 1 54 0v33Z M19.5 60V26A10 10 0 0 1 29.5 16H46v10H32a2.5 2.5 0 0 0-2.5 2.5V32H46v10H29.5v18Z\"/></svg>",
"lockup_h": "<svg class=\"pm\" viewBox=\"0 -4 232.44 64.00\" aria-hidden=\"true\" focusable=\"false\"><g transform=\"scale(1.0000) translate(-6 -2)\"><path fill=\"currentColor\" fill-rule=\"evenodd\" d=\"M6 60V28a26 26 0 0 1 52 0v32Z M20.0 60V26.5A10.5 10.5 0 0 1 30.5 16H45.0v8.6H32.5a3.5 3.5 0 0 0-3.5 3.5V32h16v8.6H29.0V60Z\"/></g><g fill=\"currentColor\"><path transform=\"translate(68.24 58.00) scale(0.05354 -0.05354)\" d=\"M115 0V494H30V600H115V637Q115 687 132.5 724.0Q150 761 192.0 781.5Q234 802 305 802Q333 802 358 799V695Q344 698 324 698Q287 698 273.5 682.0Q260 666 260 632V600H358V494H260V0Z\"/><path transform=\"translate(87.21 58.00) scale(0.05354 -0.05354)\" d=\"M271 -15Q169 -15 119.0 48.5Q69 112 69 218V600H218V244Q218 179 241.5 143.0Q265 107 316 107Q379 107 413.0 156.0Q447 205 447 276V600H596V0H462V90H438Q423 44 382.5 14.5Q342 -15 271 -15Z\"/><path transform=\"translate(121.45 58.00) scale(0.05354 -0.05354)\" d=\"M76 0V600H211V492H235Q241 521 257.0 547.0Q273 573 307.0 589.5Q341 606 399 606H428V471H387Q301 471 263.0 427.0Q225 383 225 294V0Z\"/><path transform=\"translate(144.17 58.00) scale(0.05354 -0.05354)\" d=\"M162 0Q139 27 125.5 65.0Q112 103 112 179V494H30V600H114V750H259V600H358V494H260V196Q260 116 278.0 74.5Q296 33 321 6V0Z\"/><path transform=\"translate(163.14 58.00) scale(0.05354 -0.05354)\" d=\"M78 0V800H226V366H274L449 600H608V594L397 320L632 6V0H457L274 254H226V0Z\"/><path transform=\"translate(195.88 58.00) scale(0.05354 -0.05354)\" d=\"M280 -13Q198 -13 144.0 27.5Q90 68 63.5 138.5Q37 209 37 301Q37 395 64.0 465.0Q91 535 145.0 574.0Q199 613 280 613Q344 613 384.5 587.5Q425 562 445 522H469V600H606V0H469V78H445Q425 41 385.0 14.0Q345 -13 280 -13ZM317 95Q381 95 419.5 151.0Q458 207 458 300Q458 394 419.5 449.5Q381 505 317 505Q257 505 223.5 453.0Q190 401 190 300Q190 199 223.5 147.0Q257 95 317 95Z\"/></g></svg>",
"lockup_s": "<svg class=\"pm\" viewBox=\"0 0 164.20 125.89\" aria-hidden=\"true\" focusable=\"false\"><g transform=\"translate(56.10 0) scale(1.0000) translate(-6 -2)\"><path fill=\"currentColor\" fill-rule=\"evenodd\" d=\"M6 60V28a26 26 0 0 1 52 0v32Z M20.0 60V26.5A10.5 10.5 0 0 1 30.5 16H45.0v8.6H32.5a3.5 3.5 0 0 0-3.5 3.5V32h16v8.6H29.0V60Z\"/></g><g fill=\"currentColor\"><path transform=\"translate(0.00 111.76) scale(0.05354 -0.05354)\" d=\"M115 0V494H30V600H115V637Q115 687 132.5 724.0Q150 761 192.0 781.5Q234 802 305 802Q333 802 358 799V695Q344 698 324 698Q287 698 273.5 682.0Q260 666 260 632V600H358V494H260V0Z\"/><path transform=\"translate(18.97 111.76) scale(0.05354 -0.05354)\" d=\"M271 -15Q169 -15 119.0 48.5Q69 112 69 218V600H218V244Q218 179 241.5 143.0Q265 107 316 107Q379 107 413.0 156.0Q447 205 447 276V600H596V0H462V90H438Q423 44 382.5 14.5Q342 -15 271 -15Z\"/><path transform=\"translate(53.21 111.76) scale(0.05354 -0.05354)\" d=\"M76 0V600H211V492H235Q241 521 257.0 547.0Q273 573 307.0 589.5Q341 606 399 606H428V471H387Q301 471 263.0 427.0Q225 383 225 294V0Z\"/><path transform=\"translate(75.93 111.76) scale(0.05354 -0.05354)\" d=\"M162 0Q139 27 125.5 65.0Q112 103 112 179V494H30V600H114V750H259V600H358V494H260V196Q260 116 278.0 74.5Q296 33 321 6V0Z\"/><path transform=\"translate(94.90 111.76) scale(0.05354 -0.05354)\" d=\"M78 0V800H226V366H274L449 600H608V594L397 320L632 6V0H457L274 254H226V0Z\"/><path transform=\"translate(127.64 111.76) scale(0.05354 -0.05354)\" d=\"M280 -13Q198 -13 144.0 27.5Q90 68 63.5 138.5Q37 209 37 301Q37 395 64.0 465.0Q91 535 145.0 574.0Q199 613 280 613Q344 613 384.5 587.5Q425 562 445 522H469V600H606V0H469V78H445Q425 41 385.0 14.0Q345 -13 280 -13ZM317 95Q381 95 419.5 151.0Q458 207 458 300Q458 394 419.5 449.5Q381 505 317 505Q257 505 223.5 453.0Q190 401 190 300Q190 199 223.5 147.0Q257 95 317 95Z\"/></g></svg>",
"tab16": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABJklEQVR42sWRvUoDURCFv9msf1hoZyFCGmEjWks2iIkSi9gIIZ2FQWx9BX0Gn0GsrUwjCSmMaB1ICBEsxMJGK8F13bHYmFzWJQQi+HX3zsyZc88VIlyvry7YCf8YZQ9huXfdUeRyEu8s03h4MfvFPFTd1K5FcA4yTzyvIrK/edO6+iVQTa9sWxJUgAmG4wXKztZtu94XqGWT0+JNdUCWGI3H9znfKVS6HzaAeDMlUGNY7hUOc41W05yquc6pwAmQnH2zi8CFFZaCvNHn69dnMTocRYU8gN3buGjUnnN33SeAetpxEdkYDGkG/QkvdBwKKJbxH4Gx5Qj0YLDWcKCSMBzEk220y0A5JoM+FmMy1EE9kyqgrMVlMJKAqpaA2Az+7An/LzA232GMWfyHzvuOAAAAAElFTkSuQmCC",
"full16": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABJUlEQVR42sWRz0oCYRTFf3dmkDbSbqCwTQQzPUGkgRriE6TQrnqGwKB1u54jwjbtIxzB0hdIC7fppjb92SQ1t0WMfZlOgUFnd7977uGc8wlj0Mh4yYFK0UaWABDt2ujZ2sXN0yhXzKFawnb7fgVlD0iOcB9BDrKXnUOB8JtAtYTt9vxjYIM4qFSzzc5mJGJF727fr/x4/BGnXE8v735x0Mh4yVeVnmH7NlRr6z7VDsonvEXkYNWvIeSAB03YqXxw9ewADFSKlpFZQ9leb7XPY3zM8hIWgFMHwBI89HN7t9Cp0YJ62ttXZMc4nDfK8AEcAFUS5ndEtlWlgLA4vgtmhgKTkGte583Z6GAIiykR6yBI+0fAyvgOfiGAMjexg7+K8P8CU+Md2cpUHzmzh4cAAAAASUVORK5CYII=",
"banner": " ▄▄▄▄▄▄▄▄\n ▄████████████▄\n ▄██████████████████\n ▄█████████▀▀▀▀▀▀▀████▄\n ▄██████▀ █████▄\n ██████▀ ██████\n ██████ █████████████\n ██████ ▀▀▀▀▀▀▀██████\n ██████ ██████\n ██████ ▄▄▄▄▄▄▄██████\n ██████ █████████████\n ██████ █████████████\n ██████ █████████████\n ██████ █████████████\n",
"color": "#c03a28",
"ink": "#0d0d0f"
}

12
website/data/logo/r.json Normal file
View file

@ -0,0 +1,12 @@
{
"display": "<svg class=\"pm\" viewBox=\"0 0 104 114\" aria-hidden=\"true\" focusable=\"false\"><defs><clipPath id=\"crd\" clip-rule=\"evenodd\"><path clip-rule=\"evenodd\" d=\"M2 112V42A40 40 0 0 1 42 2H62A40 40 0 0 1 102 42V112Z M63 65a11 11 0 1 0-22 0a11 11 0 1 0 22 0Z\"/></clipPath><linearGradient id=\"grd\" gradientUnits=\"userSpaceOnUse\" x1=\"0\" y1=\"2\" x2=\"0\" y2=\"112\"><stop offset=\"0\" stop-color=\"#c9803f\"/><stop offset=\".6\" stop-color=\"#af6428\"/><stop offset=\"1\" stop-color=\"#9a561f\"/></linearGradient></defs><g fill=\"none\" stroke=\"url(#grd)\" stroke-width=\"8\" stroke-linejoin=\"round\"><path d=\"M6 108V42A36 36 0 0 1 42 6H62A36 36 0 0 1 98 42V108Z\"/><path d=\"M52 8V106 M8 65H96 M52 65L4 31.1 M52 65L4 98.9 M52 65L100 98.9\" clip-path=\"url(#crd)\"/><path d=\"M52 22H79M52 37H69\" stroke-linecap=\"round\"/><circle cx=\"52\" cy=\"65\" r=\"15\"/></g></svg>",
"flat": "<svg class=\"pm\" viewBox=\"0 0 104 114\" aria-hidden=\"true\" focusable=\"false\"><defs><clipPath id=\"c\" clip-rule=\"evenodd\"><path clip-rule=\"evenodd\" d=\"M2 112V42A40 40 0 0 1 42 2H62A40 40 0 0 1 102 42V112Z M63 65a11 11 0 1 0-22 0a11 11 0 1 0 22 0Z\"/></clipPath></defs><g fill=\"none\" stroke=\"currentColor\" stroke-width=\"8\" stroke-linejoin=\"round\"><path d=\"M6 108V42A36 36 0 0 1 42 6H62A36 36 0 0 1 98 42V108Z\"/><path d=\"M52 8V106 M8 65H96 M52 65L4 31.1 M52 65L4 98.9 M52 65L100 98.9\" clip-path=\"url(#c)\"/><path d=\"M52 22H79M52 37H69\" stroke-linecap=\"round\"/><circle cx=\"52\" cy=\"65\" r=\"15\"/></g></svg>",
"min": "<svg class=\"pm\" viewBox=\"0 0 104 114\" aria-hidden=\"true\" focusable=\"false\"><g fill=\"none\" stroke=\"currentColor\" stroke-width=\"11\" stroke-linejoin=\"round\"><path d=\"M6 108V42A36 36 0 0 1 42 6H62A36 36 0 0 1 98 42V108Z\"/><path d=\"M52 8V106M8 65H96\"/><path d=\"M52 23H78M52 39H68\" stroke-linecap=\"round\"/></g><circle cx=\"52\" cy=\"65\" r=\"19\" fill=\"currentColor\"/></svg>",
"lockup_h": "<svg class=\"pm\" viewBox=\"0 -4 233.17 64.00\" aria-hidden=\"true\" focusable=\"false\"><g transform=\"scale(0.5273) translate(-2 -2)\"><defs><clipPath id=\"c\" clip-rule=\"evenodd\"><path clip-rule=\"evenodd\" d=\"M2 112V42A40 40 0 0 1 42 2H62A40 40 0 0 1 102 42V112Z M63 65a11 11 0 1 0-22 0a11 11 0 1 0 22 0Z\"/></clipPath></defs><g fill=\"none\" stroke=\"currentColor\" stroke-width=\"8\" stroke-linejoin=\"round\"><path d=\"M6 108V42A36 36 0 0 1 42 6H62A36 36 0 0 1 98 42V108Z\"/><path d=\"M52 8V106 M8 65H96 M52 65L4 31.1 M52 65L4 98.9 M52 65L100 98.9\" clip-path=\"url(#c)\"/><path d=\"M52 22H79M52 37H69\" stroke-linecap=\"round\"/><circle cx=\"52\" cy=\"65\" r=\"15\"/></g></g><g fill=\"currentColor\"><path transform=\"translate(68.97 58.00) scale(0.05354 -0.05354)\" d=\"M115 0V494H30V600H115V637Q115 687 132.5 724.0Q150 761 192.0 781.5Q234 802 305 802Q333 802 358 799V695Q344 698 324 698Q287 698 273.5 682.0Q260 666 260 632V600H358V494H260V0Z\"/><path transform=\"translate(87.94 58.00) scale(0.05354 -0.05354)\" d=\"M271 -15Q169 -15 119.0 48.5Q69 112 69 218V600H218V244Q218 179 241.5 143.0Q265 107 316 107Q379 107 413.0 156.0Q447 205 447 276V600H596V0H462V90H438Q423 44 382.5 14.5Q342 -15 271 -15Z\"/><path transform=\"translate(122.17 58.00) scale(0.05354 -0.05354)\" d=\"M76 0V600H211V492H235Q241 521 257.0 547.0Q273 573 307.0 589.5Q341 606 399 606H428V471H387Q301 471 263.0 427.0Q225 383 225 294V0Z\"/><path transform=\"translate(144.90 58.00) scale(0.05354 -0.05354)\" d=\"M162 0Q139 27 125.5 65.0Q112 103 112 179V494H30V600H114V750H259V600H358V494H260V196Q260 116 278.0 74.5Q296 33 321 6V0Z\"/><path transform=\"translate(163.87 58.00) scale(0.05354 -0.05354)\" d=\"M78 0V800H226V366H274L449 600H608V594L397 320L632 6V0H457L274 254H226V0Z\"/><path transform=\"translate(196.60 58.00) scale(0.05354 -0.05354)\" d=\"M280 -13Q198 -13 144.0 27.5Q90 68 63.5 138.5Q37 209 37 301Q37 395 64.0 465.0Q91 535 145.0 574.0Q199 613 280 613Q344 613 384.5 587.5Q425 562 445 522H469V600H606V0H469V78H445Q425 41 385.0 14.0Q345 -13 280 -13ZM317 95Q381 95 419.5 151.0Q458 207 458 300Q458 394 419.5 449.5Q381 505 317 505Q257 505 223.5 453.0Q190 401 190 300Q190 199 223.5 147.0Q257 95 317 95Z\"/></g></svg>",
"lockup_s": "<svg class=\"pm\" viewBox=\"0 0 164.20 125.89\" aria-hidden=\"true\" focusable=\"false\"><g transform=\"translate(55.74 0) scale(0.5273) translate(-2 -2)\"><defs><clipPath id=\"c\" clip-rule=\"evenodd\"><path clip-rule=\"evenodd\" d=\"M2 112V42A40 40 0 0 1 42 2H62A40 40 0 0 1 102 42V112Z M63 65a11 11 0 1 0-22 0a11 11 0 1 0 22 0Z\"/></clipPath></defs><g fill=\"none\" stroke=\"currentColor\" stroke-width=\"8\" stroke-linejoin=\"round\"><path d=\"M6 108V42A36 36 0 0 1 42 6H62A36 36 0 0 1 98 42V108Z\"/><path d=\"M52 8V106 M8 65H96 M52 65L4 31.1 M52 65L4 98.9 M52 65L100 98.9\" clip-path=\"url(#c)\"/><path d=\"M52 22H79M52 37H69\" stroke-linecap=\"round\"/><circle cx=\"52\" cy=\"65\" r=\"15\"/></g></g><g fill=\"currentColor\"><path transform=\"translate(0.00 111.76) scale(0.05354 -0.05354)\" d=\"M115 0V494H30V600H115V637Q115 687 132.5 724.0Q150 761 192.0 781.5Q234 802 305 802Q333 802 358 799V695Q344 698 324 698Q287 698 273.5 682.0Q260 666 260 632V600H358V494H260V0Z\"/><path transform=\"translate(18.97 111.76) scale(0.05354 -0.05354)\" d=\"M271 -15Q169 -15 119.0 48.5Q69 112 69 218V600H218V244Q218 179 241.5 143.0Q265 107 316 107Q379 107 413.0 156.0Q447 205 447 276V600H596V0H462V90H438Q423 44 382.5 14.5Q342 -15 271 -15Z\"/><path transform=\"translate(53.21 111.76) scale(0.05354 -0.05354)\" d=\"M76 0V600H211V492H235Q241 521 257.0 547.0Q273 573 307.0 589.5Q341 606 399 606H428V471H387Q301 471 263.0 427.0Q225 383 225 294V0Z\"/><path transform=\"translate(75.93 111.76) scale(0.05354 -0.05354)\" d=\"M162 0Q139 27 125.5 65.0Q112 103 112 179V494H30V600H114V750H259V600H358V494H260V196Q260 116 278.0 74.5Q296 33 321 6V0Z\"/><path transform=\"translate(94.90 111.76) scale(0.05354 -0.05354)\" d=\"M78 0V800H226V366H274L449 600H608V594L397 320L632 6V0H457L274 254H226V0Z\"/><path transform=\"translate(127.64 111.76) scale(0.05354 -0.05354)\" d=\"M280 -13Q198 -13 144.0 27.5Q90 68 63.5 138.5Q37 209 37 301Q37 395 64.0 465.0Q91 535 145.0 574.0Q199 613 280 613Q344 613 384.5 587.5Q425 562 445 522H469V600H606V0H469V78H445Q425 41 385.0 14.0Q345 -13 280 -13ZM317 95Q381 95 419.5 151.0Q458 207 458 300Q458 394 419.5 449.5Q381 505 317 505Q257 505 223.5 453.0Q190 401 190 300Q190 199 223.5 147.0Q257 95 317 95Z\"/></g></svg>",
"tab16": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAQCAYAAADJViUEAAAB4klEQVR42o2TPWhTYRSGn/Pdm7Qd/NnqpoPYqIvVguAP7SwUmlRrhw5qrx1dHF0c7OoiFElTOyhOpjFrJ6uIgihSKKYloiBCwSJ00bTJva9DbtubEKHveM77nPN+Bz4joYUgk3PGLYl+oCfR+gt8FJrLFVbLO0UDmL9xrPuQ3/3cIGuwLrQko8dhQxF6ZXI10CDQK3jR3dOYuPKouuUADqe68gYjwP3tTe9otrA6jngicRBRk6Jvkp4iKxtc3aqlZgDs5dSp84qi94iH2bnK3Z1IxckTgTM3S6veGXwQ3AEGXKToOlCv400nXV7kfgBImjazXD3tHcgWKhe2w8YDoCFj3JnUB1Ydm1v5nYQjz44DmNk9SQt+PZwCGJuv/gK+msg4sBSm7SQoMCPqjW/6zAsZ2qzXZtg7cw3o8umgUtCXNyyIR02EHn9uFr4vtfusFGQWDU5j9raZl1RkGrGkCYSsjKPenKeLghU/jplGOtd0mmdtGwSG0Y8UxqU0gB+P/pmdrZzZjT158g2mSwn4da7wZXC3fzvzGYHr9GbfC7OCUkwWUy4a7eTrCA/n1zYs4nEM54fzaxv7hvcrKwWZReAsUGw5kunI3sew9TZuFPjkA8vAAHCtZaoMQWjY5f8sXv4HrfW+WZX2x04AAAAASUVORK5CYII=",
"full16": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAQCAYAAADJViUEAAAChUlEQVR42nWTT4jUZRjHP993fr/ZSSSKoFu4UDC/TQNLErrUVQndSaigw2o0oyRG7KkgFAKxS9TFIpp1W4I6eHCmdutSHcwgoaK10FndFkwIRNSD5u7On9/77TA7y4T1PT087/u8z/f7fd5HDKH58paHnHT3AtuwNg7yhtvgX9LEM7s+uvTXIB8GQaNaftWF3gLWdqMzwrPAVuFZxA9BeqrXC5ca1Wz/oEYAjVq2D/OeCOOVqQtnAOZeeuz+7obuTQhvS14BIHrE8qTFoT31i5/p5MHNG9NOfkVmb+XEwuzg1VMT2QMqcl1SE+j2+XseqWW7vqFd3JQUO/HNiEuBpDWsX6GUwyo2p2X+7uRh7oWZ81cBmtXs2HKpsyNER7CWovL5Rq38+qA4KRaSNbvGLb84Enpj6wbKP2E9mgQFsH9Emoxm5lQ1e6aU9w6s5Ms7giDEMLV7+sLnAq+zilpFjKy7Xam3vk1D3CpZ7UJ6OYT4LvBHVP5Oszr2Af+BZG2Oo83a2Bu5QdG/Wq6EGHaOn2h9//VrWx7uLPcWm7XsKqgN4OhRwbU1XdwLbOvrCSWItNPiEkAnp2Mh9c/bQ/f7nQW/Veqt/QY1atmk7GfTbvurL2rln2Ontx3II5x+rt56X+DmK9nHAGpWx45iPyglhyPdadDmQs6hPPFOrIOI47a+FP4Q/Gck31dwcgTpWuhTZTTSOy+FO2maPr77k4U5uve8BUC3dHjPVOubEP0EaClQ+B14BCCJuCx4GnmiUl84yf9gfPribeBAo1b+zvCp8Y2koLAYo88B9/3706+UjK4rWZloVLNVhlYM61whaDGB/CzSk8Dzw53c35l5o1130ZBvQTz7D8VtHXvXpMGjAAAAAElFTkSuQmCC",
"banner": " ▄▄▄▄▄▄▄▄▄▄▄▄\n ▄▄████▀▀████▀▀████▄▄\n ▄██▀▀ ██ ▀▀██▄\n ██▀ ██████████ ▀██\n ███ ██ ▀██\n ████▄ ███████ ██\n ██▀▀██▄▄ ██ ██\n ██ ▀▀██▄▄██████▄ ██\n ██ ▀██▀▀ ▀▀██ ██\n ███████████ ███████████\n ██ ▀██▄ ▄██▀ ██\n ██ ▄██████████████▄ ██\n ██ ▄▄██▀▀ ▀██▀ ▀▀██▄▄ ██\n ████▀▀ ██ ▀▀████\n ██▀ ██ ▀██\n ██▄▄▄▄▄▄▄▄▄▄████▄▄▄▄▄▄▄▄▄▄██\n ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀",
"color": "#af6428",
"ink": "#151515"
}

View file

@ -11,6 +11,13 @@ HERE="$(cd "$(dirname "$0")" && pwd)"
SRCROOT="${FURTKA_SRCROOT:-/srv/furtka-site}" SRCROOT="${FURTKA_SRCROOT:-/srv/furtka-site}"
WEBROOT="${FURTKA_WEBROOT:-/var/www/furtka.org}" WEBROOT="${FURTKA_WEBROOT:-/var/www/furtka.org}"
# The runner job runs as root inside the container; hand the trees back
# to the VM user no matter how this script ends, so the manual deploy.sh
# path keeps working even after a failed build.
if [ "$(id -u)" = 0 ]; then
trap 'chown -R "${FURTKA_OWNER:-1000:1000}" "$SRCROOT" "$WEBROOT"' EXIT
fi
echo "==> rsync website/ → $SRCROOT" echo "==> rsync website/ → $SRCROOT"
rsync -az --delete \ rsync -az --delete \
--exclude='.hugo_build.lock' \ --exclude='.hugo_build.lock' \

View file

@ -6,8 +6,13 @@ enableRobotsTXT = true
[params] [params]
description = "Open-source home server OS — simple enough for everyone." description = "Open-source home server OS — simple enough for everyone."
version = "26.18-alpha" version = "26.20-alpha"
contactEmail = "hallo@furtka.org" contactEmail = "hallo@furtka.org"
# Forgejo repo the download page reads its latest release (ISO asset,
# size, date, checksum) from at build time. `version` above is only the
# fallback when that lookup fails.
forgejoURL = "https://forgejo.sourcegate.online"
repoPath = "daniel/furtka"
[markup.goldmark.renderer] [markup.goldmark.renderer]
unsafe = true unsafe = true
@ -32,5 +37,12 @@ enableRobotsTXT = true
[build] [build]
writeStats = true writeStats = true
# The release lookup must be fresh on every build — Hugo's default keeps
# remote resources cached forever, which would pin the download page to
# whatever release existed on the first build.
[caches]
[caches.getresource]
maxAge = 0
[minify] [minify]
minifyOutput = true minifyOutput = true

View file

@ -0,0 +1,63 @@
{{ define "main" }}
{{- $r := partialCached "release.html" . "release" -}}
{{- $file := path.Base $r.url -}}
<article class="download">
<header class="dl-hero">
<p class="status-chip"><span class="mono">{{ $r.version }}</span></p>
<h1>{{ .Title }}</h1>
<p class="lede">{{ .Description }}</p>
<div class="dl-card">
<a class="cta cta--primary" href="{{ $r.url }}" download>
<svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M5 21h14"/></svg>
{{ .Params.button }}
</a>
<dl class="dl-meta">
<div><dt>ISO</dt><dd class="mono">{{ $file }}</dd></div>
{{ with $r.size }}<div><dt>{{ $.Params.size_label }}</dt><dd>{{ lang.FormatNumber 1 (div (float .) 1e9) }} GB</dd></div>{{ end }}
{{ with $r.date }}<div><dt>{{ $.Params.released }}</dt><dd>{{ time.Format ":date_long" . }}</dd></div>{{ end }}
</dl>
<p class="dl-host">{{ .Params.host_note }}</p>
</div>
<p class="dl-alpha">{{ .Params.alpha | markdownify }}</p>
</header>
<section class="dl-section">
<p class="section-eyebrow">{{ .Params.need_label }}</p>
<ul class="dl-needs">
{{ range .Params.needs }}<li>{{ . | markdownify }}</li>{{ end }}
</ul>
</section>
<section class="dl-section">
<p class="section-eyebrow">{{ .Params.steps_label }}</p>
<ol class="dl-steps">
{{ range .Params.steps }}
<li class="step-card">
<h2>{{ .title }}</h2>
<div class="step-body">{{ replace .body "{iso}" $file | markdownify }}</div>
</li>
{{ end }}
</ol>
</section>
<section class="dl-section">
<p class="section-eyebrow">{{ .Params.verify_label }}</p>
{{ with $r.sha256 }}
<p>{{ $.Params.verify_intro }}</p>
<pre class="dl-hash"><code>{{ . }}</code></pre>
<pre class="dl-cmds"><code><span class="c"># Linux</span>
sha256sum {{ $file }}
<span class="c"># macOS</span>
shasum -a 256 {{ $file }}
<span class="c"># Windows (PowerShell)</span>
Get-FileHash {{ $file }}</code></pre>
{{ else }}
<p class="dl-muted">{{ $.Params.verify_missing }}</p>
{{ end }}
</section>
<p class="dl-older"><a href="{{ $r.all }}">{{ .Params.older }} →</a></p>
</article>
{{ end }}

View file

@ -1,23 +1,41 @@
{{ define "main" }} {{ define "main" }}
{{- $r := partialCached "release.html" . "release" -}}
{{- $dl := "download/" | relLangURL -}}
<article class="home"> <article class="home">
<header class="hero"> <header class="hero">
{{ with .Params.status }} <p class="status-chip reveal"><span class="mono">{{ $r.version }}</span> — {{ .Params.status_text }}</p>
<p class="status-chip reveal">{{ . | safeHTML }}</p>
{{ end }}
<h1 class="reveal">{{ .Title }}</h1> <h1 class="reveal">{{ .Title }}</h1>
{{ with site.Params.description }}<p class="lede reveal">{{ . }}</p>{{ end }} {{ with site.Params.description }}<p class="lede reveal">{{ . }}</p>{{ end }}
<p class="cta-row reveal"> <p class="cta-row reveal">
<a class="cta cta--primary" href="https://forgejo.sourcegate.online/daniel/furtka/releases"> <a class="cta cta--primary" href="{{ $dl }}">
{{ if eq site.Language.Lang "de" }}Neuestes Release{{ else }}Latest release{{ end }} {{ .Params.cta_download }}
<span aria-hidden="true">→</span> <span aria-hidden="true">→</span>
</a> </a>
<a class="cta cta--ghost" href="{{ site.Params.forgejoURL }}/{{ site.Params.repoPath }}">{{ .Params.cta_source }}</a>
</p> </p>
{{ with .Params.poll_link }}
<p class="reveal"><a class="home-poll" href="{{ . }}">{{ partial "mark-m.html" (dict "class" "" "id" "hm") }}<span>{{ $.Params.poll_text }} →</span></a></p>
{{ end }}
</header> </header>
{{ with .Params.intro }} {{ with .Params.intro }}
<section class="intro">{{ . | markdownify }}</section> <section class="intro">{{ . | markdownify }}</section>
{{ end }} {{ end }}
{{ with .Params.steps }}
<section class="feature-section">
{{ with $.Params.steps_label }}<p class="section-eyebrow">{{ . }}</p>{{ end }}
<ol class="how-steps">
{{ range . }}
<li class="how-step" data-gsap="card">
<h3>{{ .title }}</h3>
<p>{{ .body | markdownify }}</p>
</li>
{{ end }}
</ol>
</section>
{{ end }}
{{ if .Params.features_today }} {{ if .Params.features_today }}
<section class="feature-section"> <section class="feature-section">
{{ with .Params.features_today_label }}<p class="section-eyebrow">{{ . }}</p>{{ end }} {{ with .Params.features_today_label }}<p class="section-eyebrow">{{ . }}</p>{{ end }}
@ -40,6 +58,16 @@
</section> </section>
{{ end }} {{ end }}
{{ with .Params.band_title }}
<section class="dl-band" data-gsap="card">
<div>
<h2>{{ . }}</h2>
<p>{{ $.Params.band_text }}</p>
</div>
<a class="cta cta--primary" href="{{ $dl }}">{{ $.Params.cta_download }} <span aria-hidden="true">→</span></a>
</section>
{{ end }}
{{ with .Content }} {{ with .Content }}
<section class="prose closer">{{ . }}</section> <section class="prose closer">{{ . }}</section>
{{ end }} {{ end }}

View file

@ -1,6 +1,2 @@
<svg class="gate-mark" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"> {{- /* Furtka mark (M, small-size tier) — generated by tools/brand-assets.py from logo-assets.py geometry. */ -}}
<path d="M4 20 V12 A9 9 0 0 1 20 12 V20"/> <svg class="gate-mark" width="1em" height="1em" viewBox="0 0 64 64" aria-hidden="true" focusable="false"><path fill="currentColor" fill-rule="evenodd" d="M5 60V27a27 27 0 0 1 54 0v33Z M19.5 60V26A10 10 0 0 1 29.5 16H46v10H32a2.5 2.5 0 0 0-2.5 2.5V32H46v10H29.5v18Z"/></svg>
<line x1="12" y1="5" x2="12" y2="20"/>
<line x1="3" y1="20" x2="21" y2="20"/>
<line x1="15" y1="12" x2="15" y2="14.5"/>
</svg>

View file

@ -1,16 +1,25 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<script>document.documentElement.classList.replace('no-js','js');</script> <script>document.documentElement.classList.replace('no-js','js');</script>
<title>{{ if .IsHome }}{{ site.Title }} — {{ site.Params.description }}{{ else }}{{ .Title }} · {{ site.Title }}{{ end }}</title> {{ if eq .Type "poll" }}{{/* Coin-flip which mark sits left/first, so neither side collects the
"first thing on the page" votes. Decided before first paint (no reflow), applied by CSS `order`,
and kept for the tab session so a reload or a vote doesn't shuffle the page under the visitor. */}}
<script>(function(){var k='furtka-logo-order',o=null;try{o=sessionStorage.getItem(k)}catch(e){}if(o!=='mr'&&o!=='rm'){o=Math.random()<0.5?'mr':'rm';try{sessionStorage.setItem(k,o)}catch(e){}}document.documentElement.setAttribute('data-logo-order',o)})();</script>
{{ end }}<title>{{ if .IsHome }}{{ site.Title }} — {{ site.Params.description }}{{ else }}{{ .Title }} · {{ site.Title }}{{ end }}</title>
<meta name="description" content="{{ with .Params.description }}{{ . }}{{ else }}{{ site.Params.description }}{{ end }}"> <meta name="description" content="{{ with .Params.description }}{{ . }}{{ else }}{{ site.Params.description }}{{ end }}">
<meta name="theme-color" content="#f7f6f3" media="(prefers-color-scheme: light)"> <meta name="theme-color" content="#f7f6f3" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0d0d0f" media="(prefers-color-scheme: dark)"> <meta name="theme-color" content="#0d0d0f" media="(prefers-color-scheme: dark)">
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<meta property="og:site_name" content="{{ site.Title }}"> <meta property="og:site_name" content="{{ site.Title }}">
<meta property="og:title" content="{{ if .IsHome }}{{ site.Title }}{{ else }}{{ .Title }} · {{ site.Title }}{{ end }}"> <meta property="og:title" content="{{ if .IsHome }}{{ site.Title }}{{ else }}{{ .Title }} · {{ site.Title }}{{ end }}">
<meta property="og:description" content="{{ with .Params.description }}{{ . }}{{ else }}{{ site.Params.description }}{{ end }}"> <meta property="og:description" content="{{ with .Params.description }}{{ . }}{{ else }}{{ site.Params.description }}{{ end }}">
<meta property="og:type" content="{{ if .IsPage }}article{{ else }}website{{ end }}"> <meta property="og:type" content="{{ if .IsPage }}article{{ else }}website{{ end }}">
<meta property="og:url" content="{{ .Permalink }}"> <meta property="og:url" content="{{ .Permalink }}">
{{ with .Params.image | default "/img/brand/social.png" }}<meta property="og:image" content="{{ . | absURL }}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">{{ end }}
{{ $parts := split .Site.Language.LanguageCode "-" }}<meta property="og:locale" content="{{ index $parts 0 }}{{ if gt (len $parts) 1 }}_{{ upper (index $parts 1) }}{{ end }}"> {{ $parts := split .Site.Language.LanguageCode "-" }}<meta property="og:locale" content="{{ index $parts 0 }}{{ if gt (len $parts) 1 }}_{{ upper (index $parts 1) }}{{ end }}">
{{ range .AllTranslations }} {{ range .AllTranslations }}
<link rel="alternate" hreflang="{{ .Lang }}" href="{{ .Permalink }}"> <link rel="alternate" hreflang="{{ .Lang }}" href="{{ .Permalink }}">

View file

@ -0,0 +1,2 @@
{{- /* Mark M — lowercase f cut out of a gate. 64-unit grid, even-odd knockout. */ -}}
<svg class="pm {{ .class }}" viewBox="0 0 64 64" aria-hidden="true" focusable="false"><path fill="currentColor" fill-rule="evenodd" d="M6 60V28a26 26 0 0 1 52 0v32Z M20.0 60V26.5A10.5 10.5 0 0 1 30.5 16H45.0v8.6H32.5a3.5 3.5 0 0 0-3.5 3.5V32h16v8.6H29.0V60Z"/></svg>

View file

@ -0,0 +1,2 @@
{{- /* Mark R — window, wheel and F. 104x114 grid, stroke 8, hub knocked out. */ -}}
<svg class="pm {{ .class }}" viewBox="0 0 104 114" aria-hidden="true" focusable="false"><defs><clipPath id="pm-rc-{{ .id }}" clip-rule="evenodd"><path clip-rule="evenodd" d="M2 112V42A40 40 0 0 1 42 2H62A40 40 0 0 1 102 42V112Z M63 65a11 11 0 1 0-22 0a11 11 0 1 0 22 0Z"/></clipPath></defs><g fill="none" stroke="currentColor" stroke-width="8" stroke-linejoin="round"><path d="M6 108V42A36 36 0 0 1 42 6H62A36 36 0 0 1 98 42V108Z"/><path d="M52 8V106 M8 65H96 M52 65L4 31.1 M52 65L4 98.9 M52 65L100 98.9" clip-path="url(#pm-rc-{{ .id }})"/><path d="M52 22H79M52 37H69" stroke-linecap="round"/><circle cx="52" cy="65" r="15"/></g></svg>

View file

@ -0,0 +1,57 @@
{{- /*
Latest Furtka release that has an ISO attached, looked up on Forgejo at
build time. Returns a dict: version, url (ISO), size (bytes, 0 = unknown),
date (time or ""), sha256 ("" when the release has no .iso.sha256), page
(release page URL), live (false when the lookup failed and we fell back
to site.Params.version). Use via partialCached "release.html" . "release".
*/ -}}
{{- $repo := printf "%s/%s" site.Params.forgejoURL site.Params.repoPath -}}
{{- $v := site.Params.version -}}
{{- $out := dict
"version" $v
"url" (printf "%s/releases/download/%s/furtka-%s.iso" $repo $v $v)
"size" 0
"date" ""
"sha256" ""
"page" (printf "%s/releases/tag/%s" $repo $v)
"all" (printf "%s/releases" $repo)
"live" false
-}}
{{- $api := printf "%s/api/v1/repos/%s/releases?limit=10" site.Params.forgejoURL site.Params.repoPath -}}
{{- with try (resources.GetRemote $api) -}}
{{- with .Err -}}
{{- warnf "release.html: release lookup failed, using site.Params.version: %s" . -}}
{{- else with .Value -}}
{{- $done := false -}}
{{- range (. | transform.Unmarshal) -}}
{{- if and (not $done) (not .draft) -}}
{{- $rel := . -}}
{{- $iso := "" -}}
{{- $sum := "" -}}
{{- range .assets -}}
{{- if strings.HasSuffix .name ".iso" }}{{ $iso = . }}{{ end -}}
{{- if strings.HasSuffix .name ".iso.sha256" }}{{ $sum = . }}{{ end -}}
{{- end -}}
{{- with $iso -}}
{{- $done = true -}}
{{- $out = merge $out (dict
"version" $rel.tag_name
"url" .browser_download_url
"size" .size
"date" (time.AsTime $rel.published_at)
"page" $rel.html_url
"live" true) -}}
{{- with $sum -}}
{{- with try (resources.GetRemote .browser_download_url) -}}
{{- with .Value -}}
{{- $hash := index (split (trim .Content " \n") " ") 0 -}}
{{- if eq (len $hash) 64 }}{{ $out = merge $out (dict "sha256" $hash) }}{{ end -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- return $out -}}

View file

@ -0,0 +1,183 @@
{{ define "main" }}
{{- $p := .Params -}}
{{/* Display order of the two marks; flip via `order: [r, m]` in front matter so neither
side gets the "first thing on the page" advantage for the whole poll. */}}
{{- $order := $p.order | default (slice "m" "r") -}}
{{- $s := $p.show -}}
<article class="{{ if $p.closed }}poll-closed{{ else }}poll{{ end }}" data-poll="{{ $p.poll }}" data-api="/api/poll/{{ $p.poll }}">
<header class="page-header">
<h1>{{ .Title }}</h1>
{{ with $p.description }}<p class="lede">{{ . }}</p>{{ end }}
</header>
{{ with $p.result }}<p class="poll-result" role="status">{{ . | markdownify }}</p>{{ end }}
<div class="prose">{{ .Content }}</div>
{{/* ── Quick vote ─────────────────────────────────────────── */}}
<div class="poll-grid" id="vote">
{{ range $k := $order }}{{ $d := index site.Data.logo $k }}
{{ $c := index $p $k }}
{{- $total := 0 }}{{ range $v := $p.final }}{{ $total = add $total $v }}{{ end }}
{{- $won := and $p.closed (eq $k (cond (gt (index $p.final "m") (index $p.final "r")) "m" "r")) }}
<section class="poll-card{{ if $won }} is-yours{{ end }}" data-choice="{{ $k }}" aria-labelledby="poll-{{ $k }}-title">
<div class="poll-art poll-art--{{ $k }}">{{ $d.display | safeHTML }}</div>
<h2 id="poll-{{ $k }}-title">{{ $c.title }}</h2>
<p class="poll-desc">{{ $c.text }}</p>
{{ if $p.closed }}
{{ $n := index $p.final $k }}
<div class="poll-bar poll-bar--final" aria-hidden="true"><i style="width: {{ div (mul $n 100) $total }}%"></i></div>
<p class="poll-count">{{ $n }} / {{ $total }}</p>
{{ else }}
<button type="button" class="poll-btn" data-vote="{{ $k }}">{{ $p.labels.vote }}</button>
<div class="poll-bar" aria-hidden="true"><i></i></div>
<p class="poll-count" data-count="{{ $k }}"></p>
{{ end }}
</section>
{{ end }}
</div>
{{ if not $p.closed }}
<p class="poll-status" role="status" aria-live="polite"
data-t-voted="{{ $p.labels.voted }}"
data-t-total="{{ $p.labels.total }}"
data-t-error="{{ $p.labels.error }}"
data-t-change="{{ $p.labels.change }}">{{ $p.labels.nojs }}</p>
{{ end }}
{{/* ── Showcase ───────────────────────────────────────────── */}}
<div class="show">
<p class="section-eyebrow show-intro">{{ $s.intro }}</p>
<section class="show-row">
<h2 class="show-h">{{ $s.sizes }}</h2>
<p class="show-p">{{ $s.sizes_text }}</p>
<div class="show-pair">
{{ range $k := $order }}{{ $d := index site.Data.logo $k }}
<div class="show-cell show-cell--{{ $k }}">
<span class="show-tag">{{ index $p $k "short" }}</span>
<div class="sizes">
<figure><span class="sz sz-96">{{ $d.flat | safeHTML }}</span><figcaption>96</figcaption></figure>
<figure><span class="sz sz-48">{{ $d.flat | safeHTML }}</span><figcaption>48</figcaption></figure>
<figure><span class="sz sz-32">{{ $d.min | safeHTML }}</span><figcaption>32</figcaption></figure>
<figure><span class="sz sz-24">{{ $d.min | safeHTML }}</span><figcaption>24</figcaption></figure>
<figure><img class="px" src="{{ $d.tab16 | safeURL }}" width="16" height="16" alt=""><figcaption>16</figcaption></figure>
</div>
</div>
{{ end }}
</div>
</section>
<section class="show-row">
<h2 class="show-h">{{ $s.name }}</h2>
<p class="show-p">{{ $s.name_text }}</p>
<div class="show-pair">
{{ range $k := $order }}{{ $d := index site.Data.logo $k }}
<div class="show-cell show-cell--{{ $k }}">
<span class="show-tag">{{ index $p $k "short" }}</span>
<div class="lock lock-h">{{ $d.lockup_h | safeHTML }}</div>
<div class="lock lock-s">{{ $d.lockup_s | safeHTML }}</div>
</div>
{{ end }}
</div>
</section>
<section class="show-row">
<h2 class="show-h">{{ $s.browser }}</h2>
<p class="show-p">{{ $s.browser_text }}</p>
<div class="show-pair">
{{ range $k := $order }}{{ $d := index site.Data.logo $k }}
<div class="show-cell show-cell--{{ $k }}">
<span class="show-tag">{{ index $p $k "short" }}</span>
<div class="mock">
<div class="tabbar"><span class="tab"><img class="px" src="{{ $d.tab16 | safeURL }}" width="16" height="16" alt="">Furtka</span></div>
<div class="urlbar">https://furtka.org</div>
<div class="uihead"><span class="uihead-mark">{{ $d.flat | safeHTML }}</span><span class="uihead-name">Furtka</span><span class="uihead-nav"><span>{{ $s.nav1 }}</span><span>{{ $s.nav2 }}</span></span></div>
<div class="uibody"><i></i><i class="s"></i></div>
</div>
</div>
{{ end }}
</div>
</section>
<section class="show-row">
<h2 class="show-h">{{ $s.phone }}</h2>
<p class="show-p">{{ $s.phone_text }}</p>
<div class="show-pair">
{{ range $k := $order }}{{ $d := index site.Data.logo $k }}
<div class="show-cell show-cell--{{ $k }}">
<span class="show-tag">{{ index $p $k "short" }}</span>
<div class="tiles"><img src="/img/logo/{{ $k }}/tile.png" width="96" height="96" alt=""><img src="/img/logo/{{ $k }}/tile-round.png" width="96" height="96" alt=""></div>
</div>
{{ end }}
</div>
</section>
<section class="show-row">
<h2 class="show-h">{{ $s.box }}</h2>
<p class="show-p">{{ $s.box_text }}</p>
<div class="show-pair">
{{ range $k := $order }}{{ $d := index site.Data.logo $k }}
<div class="show-cell show-cell--{{ $k }}">
<span class="show-tag">{{ index $p $k "short" }}</span>
<pre class="term"><i>{{ $d.banner }}</i>
<b>Furtka is ready.</b> Open http://furtka.local</pre>
<img class="shot" src="/img/logo/{{ $k }}/splash.png" width="960" height="540" alt="">
</div>
{{ end }}
</div>
</section>
<section class="show-row">
<h2 class="show-h">{{ $s.shared }}</h2>
<p class="show-p">{{ $s.shared_text }}</p>
<div class="show-pair">
{{ range $k := $order }}{{ $d := index site.Data.logo $k }}
<div class="show-cell show-cell--{{ $k }}">
<span class="show-tag">{{ index $p $k "short" }}</span>
<img class="shot" src="/img/logo/{{ $k }}/social.png" width="600" height="315" alt="">
</div>
{{ end }}
</div>
</section>
<section class="show-row">
<h2 class="show-h">{{ $s.colour }}</h2>
<p class="show-p">{{ $s.colour_text }}</p>
<div class="show-pair">
{{ range $k := $order }}{{ $d := index site.Data.logo $k }}
<div class="show-cell show-cell--{{ $k }}">
<span class="show-tag">{{ index $p $k "short" }}</span>
<div class="grounds">
<span class="ground" style="background:#f7f6f3;color:{{ $d.color }}">{{ $d.flat | safeHTML }}</span>
<span class="ground" style="background:{{ $d.ink }};color:{{ $d.color }}">{{ $d.flat | safeHTML }}</span>
<span class="ground" style="background:{{ $d.color }};color:#f7f6f3">{{ $d.flat | safeHTML }}</span>
<span class="ground ground--fg">{{ $d.flat | safeHTML }}</span>
</div>
</div>
{{ end }}
</div>
</section>
</div>
{{/* ── Vote again at the end ─────────────────────────────── */}}
{{ if not $p.closed }}
<section class="poll-final">
<h2 class="show-h">{{ $s.final }}</h2>
<div class="poll-final-row">
{{ range $k := $order }}{{ $d := index site.Data.logo $k }}
<div class="poll-final-card" data-choice="{{ $k }}">
<span class="poll-final-mark show-cell--{{ $k }}">{{ $d.flat | safeHTML }}</span>
<button type="button" class="poll-btn" data-vote="{{ $k }}">{{ $p.labels.vote }} — {{ index $p $k "short" }}</button>
<div class="poll-bar" aria-hidden="true"><i></i></div>
<p class="poll-count" data-count="{{ $k }}"></p>
</div>
{{ end }}
</div>
</section>
<p class="poll-note">{{ $p.labels.note | safeHTML }}</p>
{{ end }}
</article>
{{ if not $p.closed }}
{{ $js := resources.Get "js/poll.js" | minify | fingerprint }}
<script defer src="{{ $js.RelPermalink }}" integrity="{{ $js.Data.Integrity }}"></script>
{{ end }}
{{ end }}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -1,6 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#a33e2e" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><style>svg{color:#c03a28}@media (prefers-color-scheme:dark){svg{color:#ff6b56}}</style><path fill="currentColor" fill-rule="evenodd" d="M5 60V27a27 27 0 0 1 54 0v33Z M19.5 60V26A10 10 0 0 1 29.5 16H46v10H32a2.5 2.5 0 0 0-2.5 2.5V32H46v10H29.5v18Z"/></svg>
<path d="M4 20 V12 A9 9 0 0 1 20 12 V20"/>
<line x1="12" y1="5" x2="12" y2="20"/>
<line x1="3" y1="20" x2="21" y2="20"/>
<line x1="15" y1="12" x2="15" y2="14.5"/>
</svg>

Before

Width:  |  Height:  |  Size: 316 B

After

Width:  |  Height:  |  Size: 316 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Some files were not shown because too many files have changed in this diff Show more