Compare commits
29 commits
d9a10f76ea
...
2f7a02f1d1
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f7a02f1d1 | |||
| 91aa33b891 | |||
| 0cad2e05c8 | |||
| 4c5bfe3aef | |||
| b7b66fe136 | |||
| 7151d411c7 | |||
| 9f9e627678 | |||
| 22c2245ac4 | |||
| 20f6398abf | |||
| 8bcbaec621 | |||
| 87dcb369db | |||
| 722dac4843 | |||
| c3c59b4bea | |||
| 58a99a0d9e | |||
| 22ce03d1f0 | |||
| 5d827e5eab | |||
| aaba14731c | |||
| 9fd9d1b7d1 | |||
| 2fbafd5c77 | |||
| 8b07b57242 | |||
| 8e281fa4a6 | |||
| 32ba522d59 | |||
| 60404ae643 | |||
| 5ddda5302a | |||
| 0e05bef667 | |||
| e1968facef | |||
| bf36b7621b | |||
| c2c3cdb1ed | |||
| 0615d92c8d |
|
|
@ -12,6 +12,7 @@ on:
|
||||||
- '**/*.md'
|
- '**/*.md'
|
||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
- 'website/**'
|
- 'website/**'
|
||||||
|
- 'ops/**'
|
||||||
- 'CHANGELOG.md'
|
- 'CHANGELOG.md'
|
||||||
- 'RELEASING.md'
|
- 'RELEASING.md'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
148
CHANGELOG.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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/)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
echo " http://${ip} (fallback if the first doesn't work)"
|
for ip in "${ips[@]}"; do
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
10
assets/systemd/furtka-welcome.timer
Normal 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
|
|
@ -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>
|
||||||
|
After Width: | Height: | Size: 316 B |
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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.
|
||||||
10
docs/brand/console-banner.txt
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
▄▄▄▄▄▄▄▄
|
||||||
|
▄██████████▄
|
||||||
|
█████▀▀▀▀▀▀███
|
||||||
|
████▀ ████
|
||||||
|
████ █████████
|
||||||
|
████ ████
|
||||||
|
████ ▄▄▄▄▄████
|
||||||
|
████ █████████
|
||||||
|
████ █████████
|
||||||
|
▀▀▀▀ ▀▀▀▀▀▀▀▀▀
|
||||||
1
docs/brand/furtka-lockup-white.svg
Normal 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>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
1
docs/brand/furtka-lockup.svg
Normal 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>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
1
docs/brand/furtka-mark-small.svg
Normal 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>
|
||||||
|
After Width: | Height: | Size: 224 B |
1
docs/brand/furtka-mark.svg
Normal 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>
|
||||||
|
After Width: | Height: | Size: 236 B |
75
docs/hardware-test-2026-08.md
Normal 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).
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
124
furtka/api.py
|
|
@ -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):
|
||||||
length = int(self.headers.get("Content-Length", "0"))
|
# 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"))
|
||||||
|
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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -221,50 +221,66 @@ 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():
|
||||||
write_state("checking")
|
|
||||||
check = check_catalog()
|
|
||||||
if not check.update_available:
|
|
||||||
write_state("done", version=check.current or check.latest, note="already up to date")
|
|
||||||
return check
|
|
||||||
if not check.tarball_url or not check.sha256_url:
|
|
||||||
raise CatalogError("catalog release is missing tarball or sha256 asset")
|
|
||||||
|
|
||||||
# Downloads land in a sibling of the live catalog so half-finished
|
|
||||||
# artefacts never pollute the live tree, and stay under /var/lib/
|
|
||||||
# furtka/ so a sync interrupted by reboot can resume instead of
|
|
||||||
# starting over from /tmp (which clears).
|
|
||||||
dl_dir = catalog_dir().with_name(catalog_dir().name + _DOWNLOADS_NAME)
|
|
||||||
dl_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
tarball = dl_dir / f"furtka-apps-{check.latest}.tar.gz"
|
|
||||||
sha_file = dl_dir / f"furtka-apps-{check.latest}.tar.gz.sha256"
|
|
||||||
|
|
||||||
write_state("downloading", latest=check.latest)
|
|
||||||
_rc.download(check.tarball_url, tarball, error_cls=CatalogError)
|
|
||||||
_rc.download(check.sha256_url, sha_file, error_cls=CatalogError)
|
|
||||||
|
|
||||||
write_state("verifying", latest=check.latest)
|
|
||||||
expected = _rc.parse_sha256_sidecar(sha_file.read_text(), error_cls=CatalogError)
|
|
||||||
_rc.verify_tarball(tarball, expected, error_cls=CatalogError)
|
|
||||||
|
|
||||||
write_state("extracting", latest=check.latest)
|
|
||||||
staging = catalog_dir().with_name(catalog_dir().name + _STAGING_NAME)
|
|
||||||
if staging.exists():
|
|
||||||
shutil.rmtree(staging)
|
|
||||||
try:
|
try:
|
||||||
_rc.extract_tarball(tarball, staging, error_cls=CatalogError)
|
return _sync_locked()
|
||||||
_validate_staging(staging, check.latest)
|
except CatalogError as e:
|
||||||
except CatalogError:
|
write_state("error", error=str(e))
|
||||||
shutil.rmtree(staging, ignore_errors=True)
|
|
||||||
raise
|
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
|
||||||
|
|
||||||
write_state("swapping", latest=check.latest)
|
|
||||||
try:
|
|
||||||
_atomic_swap(staging)
|
|
||||||
except CatalogError:
|
|
||||||
shutil.rmtree(staging, ignore_errors=True)
|
|
||||||
raise
|
|
||||||
|
|
||||||
write_state("done", version=check.latest, previous=check.current)
|
def _sync_locked() -> CatalogCheck:
|
||||||
|
write_state("checking")
|
||||||
|
check = check_catalog()
|
||||||
|
if not check.update_available:
|
||||||
|
write_state("done", version=check.current or check.latest, note="already up to date")
|
||||||
return check
|
return check
|
||||||
|
if not check.tarball_url or not check.sha256_url:
|
||||||
|
raise CatalogError("catalog release is missing tarball or sha256 asset")
|
||||||
|
|
||||||
|
# Downloads land in a sibling of the live catalog so half-finished
|
||||||
|
# artefacts never pollute the live tree, and stay under /var/lib/
|
||||||
|
# furtka/ so a sync interrupted by reboot can resume instead of
|
||||||
|
# starting over from /tmp (which clears).
|
||||||
|
dl_dir = catalog_dir().with_name(catalog_dir().name + _DOWNLOADS_NAME)
|
||||||
|
dl_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
tarball = dl_dir / f"furtka-apps-{check.latest}.tar.gz"
|
||||||
|
sha_file = dl_dir / f"furtka-apps-{check.latest}.tar.gz.sha256"
|
||||||
|
|
||||||
|
write_state("downloading", latest=check.latest)
|
||||||
|
_rc.download(check.tarball_url, tarball, error_cls=CatalogError)
|
||||||
|
_rc.download(check.sha256_url, sha_file, error_cls=CatalogError)
|
||||||
|
|
||||||
|
write_state("verifying", latest=check.latest)
|
||||||
|
expected = _rc.parse_sha256_sidecar(sha_file.read_text(), error_cls=CatalogError)
|
||||||
|
_rc.verify_tarball(tarball, expected, error_cls=CatalogError)
|
||||||
|
|
||||||
|
write_state("extracting", latest=check.latest)
|
||||||
|
staging = catalog_dir().with_name(catalog_dir().name + _STAGING_NAME)
|
||||||
|
if staging.exists():
|
||||||
|
shutil.rmtree(staging)
|
||||||
|
try:
|
||||||
|
_rc.extract_tarball(tarball, staging, error_cls=CatalogError)
|
||||||
|
_validate_staging(staging, check.latest)
|
||||||
|
except CatalogError:
|
||||||
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
write_state("swapping", latest=check.latest)
|
||||||
|
try:
|
||||||
|
_atomic_swap(staging)
|
||||||
|
except CatalogError:
|
||||||
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
write_state("done", version=check.latest, previous=check.current)
|
||||||
|
return check
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
print(f"installed {target.name} to {target}")
|
except installer.InstallError as e:
|
||||||
|
print(f"error: {e}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
print(f"installed {target.name} to {target}")
|
||||||
|
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:
|
||||||
|
plan = deps.plan_install(args.source)
|
||||||
|
except deps.DependencyError as e:
|
||||||
|
print(f"error: {e}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
if plan.to_install:
|
||||||
|
installer.install_plan(plan)
|
||||||
|
to_install = list(plan.to_install)
|
||||||
else:
|
else:
|
||||||
try:
|
# Target already installed — reinstall to refresh files, then still
|
||||||
plan = deps.plan_install(args.source)
|
# run the docker phase so hooks re-fire (matches API reinstall).
|
||||||
except deps.DependencyError as e:
|
installer.install_from(installer.resolve_source(args.source))
|
||||||
print(f"error: {e}", file=sys.stderr)
|
to_install = [args.source]
|
||||||
return 2
|
|
||||||
if not plan.to_install:
|
|
||||||
# Target is already installed — re-run as a single-app install
|
|
||||||
# to refresh files (matches reinstall semantics).
|
|
||||||
target_path = installer.install_from(installer.resolve_source(args.source))
|
|
||||||
print(f"reinstalled {target_path.name} to {target_path}")
|
|
||||||
else:
|
|
||||||
targets = installer.install_plan(plan)
|
|
||||||
for t in targets:
|
|
||||||
print(f"installed {t.name} to {t}")
|
|
||||||
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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,13 +458,25 @@ 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():
|
||||||
check = check_update()
|
try:
|
||||||
if not check.update_available:
|
check = check_update()
|
||||||
write_state("done", version=check.current, note="already up to date")
|
if not check.update_available:
|
||||||
|
write_state("done", version=check.current, note="already up to date")
|
||||||
|
return check
|
||||||
|
tarball, version = prepare_update(check)
|
||||||
|
apply_update(tarball, version)
|
||||||
return check
|
return check
|
||||||
tarball, version = prepare_update(check)
|
except Exception as e:
|
||||||
apply_update(tarball, version)
|
# Without a terminal stage the settings page keeps showing
|
||||||
return check
|
# "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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
12
iso/build.sh
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
10
iso/overlay/airootfs/etc/systemd/system/furtka-issue.timer
Normal 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
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
../furtka-issue.timer
|
||||||
|
|
@ -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
|
||||||
echo " http://${ip}:5000 (fallback if the first doesn't work)"
|
for ip in "${ips[@]}"; do
|
||||||
|
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
|
After Width: | Height: | Size: 4.3 KiB |
|
|
@ -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;
|
||||||
|
|
|
||||||
2
ops/nginx/poll-ratelimit.conf
Normal 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;
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
17
ops/poll/furtka-poll.service
Normal 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
|
|
@ -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
|
|
@ -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"
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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"]
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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() == {}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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
|
||||||
|
|
@ -1,56 +1,90 @@
|
||||||
|
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.
|
||||||
"""
|
"""
|
||||||
try:
|
partition = None
|
||||||
result = subprocess.run(
|
result = _run(["findmnt", "-no", "SOURCE", "/run/archiso/bootmnt"])
|
||||||
["findmnt", "-no", "SOURCE", "/run/archiso/bootmnt"],
|
if result is not None and result.returncode == 0:
|
||||||
capture_output=True,
|
partition = result.stdout.strip() or None
|
||||||
text=True,
|
if partition is None:
|
||||||
)
|
try:
|
||||||
except FileNotFoundError:
|
partition = _boot_device_from_cmdline(Path("/proc/cmdline").read_text())
|
||||||
|
except OSError:
|
||||||
|
partition = None
|
||||||
|
if partition is None:
|
||||||
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"
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error checking SMART status for {device}: {e}")
|
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
if "PASSED" in result.stdout:
|
||||||
|
return "passed"
|
||||||
|
if "FAILED" in result.stdout:
|
||||||
|
return "failed"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
_HEALTH_SCORE = {"passed": 10, "failed": 0, "unknown": 5}
|
_HEALTH_SCORE = {"passed": 10, "failed": 0, "unknown": 5}
|
||||||
|
|
@ -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__":
|
||||||
|
|
|
||||||
1
webinstaller/static/favicon.svg
Normal 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>
|
||||||
|
After Width: | Height: | Size: 316 B |
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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 & hostname</h1>
|
<h1>Name & 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 %}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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();
|
||||||
|
})();
|
||||||
|
|
@ -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"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
40
website/content/download.de.md
Normal 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://<name>.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"
|
||||||
|
---
|
||||||
40
website/content/download.md
Normal 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://<name>.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"
|
||||||
|
---
|
||||||
58
website/content/logo.de.md
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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' \
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
63
website/layouts/download/single.html
Normal 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 }}
|
||||||
|
|
@ -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 }}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 384 B After Width: | Height: | Size: 390 B |
|
|
@ -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 }}">
|
||||||
|
|
|
||||||
2
website/layouts/partials/mark-m.html
Normal 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>
|
||||||
2
website/layouts/partials/mark-r.html
Normal 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>
|
||||||
57
website/layouts/partials/release.html
Normal 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 -}}
|
||||||
183
website/layouts/poll/single.html
Normal 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 }}
|
||||||
BIN
website/static/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
|
|
@ -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: 334 B After Width: | Height: | Size: 316 B |
BIN
website/static/img/brand/social.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
website/static/img/logo/m/social.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
website/static/img/logo/m/splash.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
website/static/img/logo/m/tile-round.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
website/static/img/logo/m/tile.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
website/static/img/logo/r/social.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
website/static/img/logo/r/splash.png
Normal file
|
After Width: | Height: | Size: 18 KiB |