CI runs pytest as root, which ignores directory permissions, so the
0o000-directory tests didn't hit the error path: the enable test went on
to call the real systemctl and failed, and the status test passed only
by accident. Monkeypatch the PermissionError instead, and stub reload so
the enable test can never shell out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
run_connect() saved the registration before bringing the tunnel up, but
a retry after a tunnel failure registered again anyway, leaving the
first box and its wg-easy peer orphaned on the gateway. Reuse the saved
registration when it's for the same gateway, and refuse a different
gateway until the box is disconnected.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the internet toggle, a published app showed up to seven buttons and
squeezed its description. Only Open stays visible; Settings, the
expose/stop-exposing toggle, Update, Reinstall and Remove move into a
"⋯" menu, and the public URL shows as a link under the description.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sessions are in-memory, so a furtka-api restart logs the user out and
/api/gateway/status returns 401. The settings page ignored that and kept
its default "not connected" panel visible. Both panels now start hidden
and a 401 redirects to /login.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
30ee044 added it to archinstall/user_configuration.json, but the
webinstaller builds its own package list in build_archinstall_config(),
so installed boxes had no wg-quick@.service and gateway connect failed
with "Unit wg-quick@furtka-gw.service does not exist".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The logo vote on furtka.org closed with M 29 · R 13 (42 votes).
- website/tools/brand-assets.py generates every brand file from the
logo-assets.py geometry: favicons (small-size tier, accent with a
dark-mode variant) for website / webinstaller / on-box UI, 180 px
apple-touch-icon, 1200x630 default share card, 640x480 syslinux splash,
and docs/brand/ (mark, small mark, lockups, 20-column console art)
- website: header mark, favicon, touch icon, default og:image; /logo/ now
shows the final result (static counts, winner highlighted, no voting
UI or poll.js); homepage link reads 'meet our new logo'
- webinstaller: M in the header + tab icon
- on-box UI: .brand mark via CSS mask in brand red (not the UI's green),
favicon on every page, brand line on the login page
- console: M art above the text of /etc/issue on the installed box
(furtka-welcome) and the live ISO (furtka-update-issue)
- ISO: build.sh swaps releng's Arch splash for the Furtka one
- README shows the lockup
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wq1hzUp4ZmCKHDuLKCnu73
- /download/ and /de/download/: big download button, file name, size and
release date, requirements (64-bit PC, UEFI or BIOS, >=16 GB disk that
gets erased, USB stick, network cable with internet, second device),
five install steps (Etcher / Rufus DD mode / dd, boot menu, Secure Boot
off, proksi.local:5000, wizard, pull stick), checksum + verify commands
when the release has one, alpha notice, link to all releases
- the release data comes from the Forgejo API at build time
(partials/release.html): newest release with an .iso asset, falling back
to params.version if the lookup fails; getresource cache disabled so a
build never pins an old release
- homepage: status chip follows the latest release, 'Download ISO' primary
CTA + 'Source code' ghost CTA, numbered how-it-works cards, download band
before the closer
- releases now upload furtka-<ver>.iso.sha256, and release.yml rebuilds the
site after publishing so the download page follows each release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wq1hzUp4ZmCKHDuLKCnu73
The force-HTTPS snippet was 'redir https://{host}{uri} permanent':
- every Host was redirected, including a bare IP that the tls-internal
block has no certificate for — devices reaching the box by IP (no mDNS
on Android / older Windows, the console IP fallback) couldn't load the
UI at all
- 301 is cached by browsers, so switching HTTPS off kept bouncing them to
a dead :443
Now a host matcher limits the redirect to <hostname>.local and <hostname>
and it's a 302. Self-update rewrites the old snippet if it is still the
untouched legacy content. Browsers that already cached the old 301 keep it
until their cache is cleared.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wq1hzUp4ZmCKHDuLKCnu73
Atom Bay Trail / Cherry Trail mini-PCs and tablets have a 64-bit CPU behind
32-bit UEFI. We picked systemd-boot there (Arch ships no ia32 build), and
archinstall 4.4's GRUB path hardcodes --target=x86_64-efi, so the installed
system could not boot. archinstall's Limine path copies both BOOTIA32.EFI
and BOOTX64.EFI and chooses the entry by /sys/firmware/efi/fw_platform_size;
the default layout's FAT /boot satisfies its layout check. 64-bit UEFI and
BIOS keep systemd-boot / GRUB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wq1hzUp4ZmCKHDuLKCnu73
The webinstaller imports archinstall internals and sed-patches one of its
files, so every ISO build silently picked up whatever archinstall Arch
shipped that day — any upstream release could break all installs with no
change on our side. build.sh now downloads the validated build from
archive.archlinux.org, checks its sha256, and serves it from a local
[furtka-pinned] repo ahead of [core] in the build's pacman.conf.
The build fails with a clear message once Arch's Python moves past the one
the pinned package targets. Verified in an archlinux:latest container:
pacman resolves 'furtka-pinned archinstall 4.4-1'. Bump steps in
iso/README.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wq1hzUp4ZmCKHDuLKCnu73
- error state gets a 'Try again' button (settings are kept) plus links back
to the disk / settings pages; the page reloads into it instead of only
recolouring the bar
- names are lowercased instead of rejected; system account names (root,
caddy, docker, …) are refused with a clear message
- plain wording: 'Server name' with a live http://<name>.local preview,
'Pick a disk' with an explicit erase warning, a readable confirm table
(language label, disk size/type) and a Back button
- the password hint says it is also the SSH / console password; the
language hint says the wizard itself is English
- wizard pages redirect to the progress page while an install is running
- progress page: aria-live phase, aria-valuenow updates, 'lost connection'
note, 'fine to close this page'
- .local fallback on the reboot page: router device list, IP may change
- live console banner says a network cable with internet is required
- target gets a systemd-networkd-wait-online --any drop-in: boards with a
second unplugged NIC (or Ethernet + Wi-Fi) waited up to 2 min at boot
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wq1hzUp4ZmCKHDuLKCnu73
- an unfinished install-state.json whose runner is gone (free lock, >60 s
old — reboot, OOM kill, runner never started) is marked 'error' instead
of blocking every later install with 409 until someone fixes it over SSH
- read_state in install_runner/updater tolerates unreadable and non-object
files
- Forgejo API + release downloads wrap OSError and HTTPException (socket
timeouts, resets, disk full) — the update/catalog check endpoints used to
drop the connection on a flaky network
- catalog sync records any failure as the 'error' stage, not just
CatalogError
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wq1hzUp4ZmCKHDuLKCnu73
updater:
- /opt/furtka/current is swapped via rename() instead of unlink+symlink,
so a power cut can't leave the box without a current version
- OSError after the flip (disk full writing the Caddyfile) now rolls back
too; rollback restores the previous Caddyfile and reloads caddy +
daemon-reload
- the health check keeps polling on read timeouts / RemoteDisconnected
instead of escaping and skipping the rollback
- run_update writes an 'error' stage for any failure (download, sha,
disk full); the settings page shows it instead of spinning on
'Downloading…'
auth:
- users.json that exists but is empty/corrupt/unreadable raises instead of
reading as 'no admin yet' — before, a truncated file made /login an open
create-admin form for the whole LAN. /login answers 503 with SSH recovery
steps instead
- save_users fsyncs before the rename
- first-run admin creation is serialised (threaded server)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wq1hzUp4ZmCKHDuLKCnu73
- app names from URL paths and JSON bodies must match APP_NAME_RE; installer
remove()/update_env() check it too. Before, {"name": ".."} or "/" sent
remove() into shutil.rmtree on /var/lib/furtka or the root filesystem
- manifests must carry a valid app name as well
- settings are validated in memory (placeholders, system paths, line breaks)
before .env is written — a rejected /etc used to be saved anyway
- values containing $ are single-quoted so compose doesn't expand them
("pa$word" became "pa"); line breaks are refused (env injection)
- ThreadingHTTPServer so status polls keep answering during slow requests;
state-changing POSTs still run one at a time
- 1 MiB body cap, 30 s socket timeout, bad Content-Length → 400
- POSTs with Sec-Fetch-Site other than same-origin are refused (apps on
other ports of the same host count as same-site for SameSite=Strict)
- unexpected exceptions return a JSON 500 instead of a dropped connection
- UI pages send the browser to /login on a 401 instead of showing an empty
app list after an API restart
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wq1hzUp4ZmCKHDuLKCnu73
- drives: classify by lsblk TRAN/ROTA (SATA SSDs were labelled HDD and lost
to bigger spinning disks); drop zram, eMMC boot/rpmb areas, read-only and
0-byte disks; grey out disks under 16 GB; rank USB disks last; warn about
>2 TB disks on BIOS (MBR); boot-stick fallback via archiso kernel args
when bootmnt is gone; timeouts on lsblk/smartctl/blkid
- step 2 only accepts a disk that was actually offered
- /install/run refuses a second concurrent install and disables the button
- archinstall's exit code is tracked: OOM kills / signals now show as errors
instead of a progress bar stuck forever
- disk-probe exceptions land in the install log instead of a bare 500
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
furtka-gateway's wg-easy v15 client (wgeasy_v15.py) generates a
PresharedKey per WireGuard client by default, which the server enforces
even if the box's own conf omits it — dropping it here breaks the
handshake silently. Include it in [Peer] when present; v14 has no
preshared_key field at all, so this stays a no-op for that path.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R3jVwAqESRjPdW9P9nvfem
Hugo sorts map keys, so `range (dict "m" … "r" …)` always put M first
and gave it the "first thing on the page" vote. The template now ranges
over an ordered list (front-matter `order:` can pin it), and a tiny
inline script on poll pages coin-flips the visual order before first
paint, applied via CSS `order` on every M/R pair and remembered in
sessionStorage so a reload or a vote doesn't shuffle the page.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y8kPxizsExc95ZhvfJmaCw
A freshly-installed box had no wg/wg-quick binary and no wg-quick@.service
unit, so wireguard.set_tunnel_enabled() would fail as soon as a box tried
to connect to a furtka-gateway. iso/overlay/packages.extra is unaffected —
that's the live-boot ISO's own package set, unrelated to what actually
lands on the installed disk.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142mP6EqCkQKmR1xCp7zfmZ
hugo.Data is not available in the alpine Hugo the deploy job uses; the
failed render left the webroot half-cleaned and root-owned because the
chown only ran after a successful build. It is an EXIT trap now.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MScAinbyMdeNc7H2BZdnnG
Both marks now appear everywhere they would have to work: five sizes
down to the real 16 px tab icon, horizontal and stacked lockups, browser
tab + site header, phone tiles, console banner + boot splash, social
card, and four colour grounds — generated from one geometry source by
website/tools/logo-assets.py (outputs committed under data/logo and
static/img/logo). The homepage links to the vote and its 'what works
today' list catches up with 26.8–26.17: password login, online app
catalog, app dependencies, opt-in HTTPS.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MScAinbyMdeNc7H2BZdnnG
A client-supplied X-Forwarded-For could mint a fresh device hash per
request. nginx now trusts only the edge hop (192.168.178.164) when
unwinding the chain and passes the result as X-Real-IP; the service
uses nothing else. setup-poll.sh --reset wipes the vote table.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MScAinbyMdeNc7H2BZdnnG
The runner job runs as root, which left /srv/furtka-site and
/var/www/furtka.org root-owned and broke the manual deploy.sh path.
Runner capacity on forge-runner-01 raised to 2 alongside this, so the
site deploy no longer queues behind an ISO build.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MScAinbyMdeNc7H2BZdnnG
Two marks (M and R) side by side, EN + DE, with the site-header and real
16 px favicon context for each. Votes go to ops/poll/pollsvc.py, a stdlib
Python service behind nginx at /api/poll/ (SQLite, systemd DynamicUser,
rate-limited). Devices are counted once via a salted hash of IP + user
agent; the privacy pages document it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MScAinbyMdeNc7H2BZdnnG
After the 26.19 → 26.20 self-update the bench had furtka-welcome.timer
linked + enabled but inactive (dead): enable only arms a timer for the
next boot. The link also pointed at /opt/furtka/versions/<ver>/ instead
of /opt/furtka/current/, unlike the installer-created links, so it would
have stopped following later updates and rollbacks. Link via current,
enable, start.
Adds docs/hardware-test-2026-08.md (timeline, five findings with root
causes / fixes / validation status, what worked, open items) and a
'Real hardware' section in iso/README.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MScAinbyMdeNc7H2BZdnnG
On dual-stack LANs network-online.target is satisfied by the IPv6 SLAAC
address about a second before DHCPv4 lands, so the first
furtka-catalog-sync run died with 'Network is unreachable' and the next
attempt was the daily timer (OnBootSec=10min + up to 6 h jitter). The
state file stayed on 'checking' and the apps page showed 'checking…'
with nothing running — first hardware bench, 2026-08-25.
- furtka-catalog-sync.service: Restart=on-failure every 20 s, up to 8
times in 15 min
- sync_catalog() writes stage 'error' + message on CatalogError, then
re-raises (body moved to _sync_locked())
- apps page shows 'last sync failed: <error>' for that stage
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MScAinbyMdeNc7H2BZdnnG
The hint lived on the rebooting page and in a confirm() popup — i.e. after
the click, with the machine restarting 3 s later. On the first hardware
bench the box promptly booted the stick again. The done state now shows a
two-step list (pull the stick → restart) above the button, and the button
says what it assumes: 'USB stick is out — restart now'.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MScAinbyMdeNc7H2BZdnnG
The live ISO auto-activates LVM/md/swap found on attached drives. The
first hardware bench's SSD still carried a Proxmox VE 'pve' VG, so six
device-mapper nodes sat on sda3 and archinstall died at 12 % with
'unable to inform the kernel of the change ... in use'.
install_run now calls diskprep.release_disk() first: swapoff/umount
leaves-first, dmsetup remove / mdadm --stop for every dm/md node stacked
on the disk (retrying until the stack is gone), wipefs -a on partitions
and disk, blockdev --rereadpt. Steps are logged at the top of the install
log (spawn_archinstall opens it in append mode now). The progress page
names this failure explicitly if it still happens.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MScAinbyMdeNc7H2BZdnnG
The /etc/issue banner on both the live ISO and the installed system was a
oneshot after network-online.target that simply dropped the http://<ip>
line when DHCP hadn't delivered yet — so a slow NIC (first hardware bench,
2026-08-24) left the screen offering only proksi.local. On the ISO,
releng's root autologin on tty1 also made agetty --reload a no-op.
- furtka-issue.timer (ISO) / furtka-welcome.timer (target) re-run the
banner script every 5 s; services drop RemainAfterExit + network wait
- fallback line is always present: every global IPv4, or a 'no IP yet —
check cable / DHCP' hint; only rewrites + reloads on change
- build.sh removes releng's getty@tty1 autologin drop-in; banner tells
users a shell is root + Enter away
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MScAinbyMdeNc7H2BZdnnG
Settings page gets a "Furtka Gateway" section: connect (gateway URL +
registration token + box name, polls /api/gateway/connect/status the same
way the update-apply flow polls /update-state.json) and disconnect, plus
a status readout (gateway URL, tunnel state). Removed "Remote access"
from the "Coming next" list now that it exists.
The Apps page's installed-app rows get an "Expose to Internet" control,
shown only when the manifest declares internet.viable — fetched lazily
per app (loadInternetSlots()) since the publish state isn't part of the
main /api/apps listing. Toggling calls the existing
GET/POST /api/apps/<name>/internet endpoints; the subdomain prompt uses a
plain window.prompt() rather than extending the settings modal, since
this needs a live network round-trip and a returned public URL that the
generic settings form has no way to express.
Verified end-to-end in a real browser against a running furtka serve +
a stub gateway server (login, publish, unpublish, connect-with-missing-
systemd-run, disconnect, and the client-side required-field validation),
not just by reading the generated HTML/JS.
Found by actually running furtka serve and clicking through the new
Settings UI: /api/gateway/status crashed the whole request with a raw
PermissionError traceback because conf_path.is_file() doesn't swallow
every OSError (confirmed directly, not assumed) when the parent directory
isn't accessible. A read-only status check must degrade to "not
configured" instead of 500ing; set_tunnel_enabled() now raises a clean
WireguardError instead of leaking an unhandled exception.
Also gives wireguard.py an env-var-overridable default path
(FURTKA_WIREGUARD_CONF / default_conf_path()), matching every other
runtime path in the codebase, instead of a single hardcoded constant.
POST /api/gateway/connect dispatches registration + tunnel setup via
systemd-run (same async+state-file+poll idiom as install/update/catalog-
sync), backed by a new `furtka gateway connect-bg` internal CLI
subcommand and furtka.gateway.run_connect()/write_state()/read_state()/
acquire_lock(). GET /api/gateway/status and /api/gateway/connect/status
round out polling; POST /api/gateway/disconnect tears the tunnel down and
deregisters synchronously (a plain HTTP call plus one systemctl action,
same posture as _do_update_settings).
GET/POST /api/apps/<name>/internet is the per-app "expose to internet"
toggle: publishes/unpublishes a route via the connected gateway's
/v1/routes API (furtka.gateway.publish_route/unpublish_route, new) and
tracks published state in paths.internet_routes_file(). Deliberately its
own endpoint rather than a boolean settings[] type — it needs a live
network round-trip and returns a public URL, which the generic settings
modal has no way to express.
_manifest_summary()/cli.py's `app list --json` both gain the internet
field, mechanically identical to how requires was added.
furtka.gateway: register/rotate-token/deregister against a furtka-gateway's
/v1/boxes/* API (stdlib urllib.request, same convention as
_release_common.py/updater.py). No local keypair generation — the
gateway's registration response carries the full WireGuard interface
config including the private key, since stock wg-easy always generates
the keypair itself (see furtka-gateway's wgeasy.py). Config persists to
paths.gateway_config_file() (0600, atomic write, same pattern as
auth.save_users).
furtka.wireguard: direct structural mirror of https.py's
snippet-write-plus-reload pattern for the wg-quick@furtka-gw systemd unit
— write/remove the .conf, flip the unit, revert the conf on failure so a
bad config can't leave the tunnel wedged.
paths.py gains gateway_config_file() and internet_routes_file()
(FURTKA_GATEWAY_CONFIG / FURTKA_INTERNET_ROUTES_FILE), following the
existing env-var-override convention.
New Manifest.internet field (viable: bool, port: int | None), parsed by
_parse_internet() following _parse_settings/_parse_requires's existing
shape. When viable, port must be one of the manifest's own declared ports
— resolves which entry is the exposable web UI for apps declaring several
(e.g. a DNS service exposing both a resolver port and an admin UI port).
catalog.py's release validation additionally rejects any internet.viable
app whose docker-compose.yaml sets privileged: true. Deliberately not a
network_mode check: a furtka-gateway reaches a host-networked container's
port exactly like a bridge-published one — the real risk is a compromised
internet-facing container also having host device/kernel access, which
privileged: true grants outright.