Compare commits

...
Sign in to create a new pull request.

27 commits

Author SHA1 Message Date
0996262fee docs(readme): gateway shipped in 26.21-alpha, roadmap + key decision
All checks were successful
CI / lint (push) Successful in 27s
CI / test (push) Successful in 1m46s
CI / validate-json (push) Successful in 25s
CI / markdown-links (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:07:04 +02:00
c00a01e940 docs(changelog): add Furtka Gateway to 26.21-alpha
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / validate-json (push) Waiting to run
CI / markdown-links (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:06:40 +02:00
cdc0c3c6fd chore: release 26.21-alpha
All checks were successful
Deploy site / deploy (push) Successful in 5s
CI / lint (push) Successful in 30s
CI / test (push) Successful in 2m55s
CI / validate-json (push) Successful in 33s
CI / markdown-links (push) Successful in 21s
Build ISO / build-iso (push) Successful in 22m11s
Release / release (push) Successful in 16m23s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016cfh8U3WV6jgc1MHH1FyAB
2026-10-23 16:57:05 +02:00
7053ef290c Merge pull request 'fix: pin archinstall, Limine on 32-bit UEFI, hostname-scoped 302 HTTPS redirect' (#20) from fix/followups-archinstall-ia32-https into main 2026-10-23 16:53:57 +02:00
24beffeaf4 style: polish on-box UI (sticky nav, softer cards, status tiles, app tile hover)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:42:46 +02:00
5830f8887a Merge pull request 'dev_gateway' (#22) from dev_gateway into main
All checks were successful
CI / lint (push) Successful in 26s
CI / test (push) Successful in 1m59s
CI / validate-json (push) Successful in 24s
CI / markdown-links (push) Successful in 23s
Build ISO / build-iso (push) Successful in 20m15s
Reviewed-on: #22
2026-10-07 16:34:47 +02:00
9076b85304 test: simulate permission errors in wireguard tests instead of chmod 000
All checks were successful
CI / lint (pull_request) Successful in 29s
CI / validate-json (pull_request) Successful in 23s
CI / markdown-links (pull_request) Successful in 21s
CI / test (pull_request) Successful in 1m47s
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>
2026-10-07 16:24:51 +02:00
2f6c31a26e style: ruff format tests/test_wireguard.py
Some checks failed
CI / lint (pull_request) Successful in 27s
CI / validate-json (pull_request) Successful in 23s
CI / markdown-links (pull_request) Successful in 23s
CI / test (pull_request) Failing after 1m42s
Fixes the CI format check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:14:24 +02:00
2f7a02f1d1 Merge remote-tracking branch 'origin/main' into dev_gateway
Some checks failed
CI / lint (pull_request) Failing after 28s
CI / validate-json (pull_request) Successful in 23s
CI / markdown-links (pull_request) Successful in 21s
CI / test (pull_request) Failing after 1m45s
# Conflicts:
#	tests/test_api.py
2026-10-07 16:11:54 +02:00
d9a10f76ea fix: reuse the saved gateway registration on connect retry
Some checks failed
CI / lint (pull_request) Failing after 29s
CI / validate-json (pull_request) Successful in 23s
CI / markdown-links (pull_request) Successful in 23s
CI / test (pull_request) Failing after 1m28s
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>
2026-10-07 15:19:34 +02:00
e251eb831a feat: group installed-app actions into an overflow menu
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>
2026-10-07 15:13:39 +02:00
0490f49f85 fix: don't show gateway as disconnected when the session expired
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>
2026-10-07 15:13:39 +02:00
f95bfcb56b fix: install wireguard-tools from the webinstaller's package list
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>
2026-10-07 15:13:39 +02:00
91aa33b891 feat(brand): Mark M is the Furtka logo — website, installer, box UI, console, boot splash
All checks were successful
CI / lint (pull_request) Successful in 30s
CI / validate-json (pull_request) Successful in 24s
CI / markdown-links (pull_request) Successful in 16s
CI / test (pull_request) Successful in 1m47s
CI / lint (push) Successful in 28s
CI / test (push) Successful in 1m56s
CI / validate-json (push) Successful in 24s
CI / markdown-links (push) Successful in 21s
Deploy site / deploy (push) Successful in 5s
Build ISO / build-iso (push) Successful in 20m27s
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
2026-09-28 15:08:49 +02:00
0cad2e05c8 feat(website): download page for the ISO, homepage with download CTA and 3-step how-it-works
- /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
2026-09-28 14:52:59 +02:00
ff5fe6f1c6 docs(changelog): archinstall pin, 32-bit UEFI, HTTPS redirect
All checks were successful
CI / lint (pull_request) Successful in 26s
CI / test (pull_request) Successful in 1m45s
CI / validate-json (pull_request) Successful in 24s
CI / markdown-links (pull_request) Successful in 21s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wq1hzUp4ZmCKHDuLKCnu73
2026-09-28 14:24:16 +02:00
2468cb3ded fix(https): redirect only the certificate's hostnames, with 302
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
2026-09-28 14:21:14 +02:00
455150f97f fix(installer): use Limine on 32-bit UEFI firmware
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
2026-09-28 14:21:14 +02:00
bfa7ee8cba build(iso): pin archinstall 4.4-1 from the Arch archive
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
2026-09-28 14:14:59 +02:00
4c5bfe3aef Merge pull request 'fix: bug scan — API hardening, safer disk picker, updater rollback, friendlier installer' (#19) from fix/bug-scan-hw-compat into main
All checks were successful
CI / lint (push) Successful in 28s
CI / test (push) Successful in 1m54s
CI / validate-json (push) Successful in 24s
CI / markdown-links (push) Successful in 16s
Deploy site / deploy (push) Successful in 4s
Build ISO / build-iso (push) Successful in 21m17s
Reviewed-on: #19
2026-09-28 14:06:09 +02:00
905845187d fix: thread wg-easy v15's preshared key into the box's wg-quick config
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
2026-09-19 13:34:45 +02:00
30ee044657 Add wireguard-tools to the installed-system package list
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
2026-08-26 14:25:15 +02:00
f88412d9e8 Add gateway-connect UI and per-app internet-exposure toggle
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.
2026-08-24 14:57:11 +02:00
91f2d6304c Fix wireguard.status()/set_tunnel_enabled() crash on unreadable conf path
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.
2026-08-24 14:57:11 +02:00
e88b57f74d Add gateway connect/disconnect and per-app internet-toggle API endpoints
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.
2026-08-24 13:35:44 +02:00
557d410eca Add gateway registration client and WireGuard tunnel management
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.
2026-08-24 13:13:25 +02:00
43bcf3c4e5 Add internet.viable manifest field and its catalog-side privileged gate
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.
2026-08-24 13:08:55 +02:00
62 changed files with 3145 additions and 96 deletions

View file

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

View file

@ -7,6 +7,29 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
## [Unreleased] ## [Unreleased]
## [26.21-alpha] - 2026-10-23
### Added
- **Furtka Gateway: apps reachable from the internet** (behind NAT/CGNAT,
no port forwarding). Settings → *Furtka Gateway*: enter the gateway URL
and registration token, the box registers and keeps a WireGuard tunnel to
a [furtka-gateway](https://forgejo.sourcegate.online/robert/furtka-gateway)
server (Traefik + wg-easy + control plane). Apps → ⋯ → *Expose to
Internet* publishes an app under its own hostname with Let's Encrypt;
*Stop exposing* takes it down again. Only apps whose manifest declares
`internet.viable: true` can be exposed. Client in `furtka/gateway.py` +
`furtka/wireguard.py`, CLI `furtka gateway …`.
- **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 ### Security
- **App names are validated everywhere.** `POST /api/apps/remove` with - **App names are validated everywhere.** `POST /api/apps/remove` with
@ -50,6 +73,13 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
full. full.
- **Apps page after an API restart** redirects to `/login` instead of - **Apps page after an API restart** redirects to `/login` instead of
showing an empty app list. showing an empty app list.
- **32-bit UEFI machines** (Atom Bay Trail / Cherry Trail) get Limine
instead of systemd-boot: Arch has no ia32 systemd-boot and archinstall's
GRUB path hardcodes `x86_64-efi`, so these boxes installed but didn't boot.
- **Force-HTTPS redirect** only covers `<hostname>.local` / `<hostname>` and
is a 302. The old 301 for every host sent devices that use the IP to a
TLS handshake that can't succeed, and stayed cached after HTTPS was
turned off. Self-update migrates the old snippet.
- **Self-update starts newly added timers immediately and links units - **Self-update starts newly added timers immediately and links units
through `current`.** `_link_new_units()` enabled a timer a release through `current`.** `_link_new_units()` enabled a timer a release
@ -68,6 +98,12 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
password hint explains it's also the SSH/console password, names are password hint explains it's also the SSH/console password, names are
lowercased instead of rejected, system account names are refused. lowercased instead of rejected, system account names are refused.
- Live console banner says a network cable with internet is required. - Live console banner says a network cable with internet is required.
- ISO builds pin archinstall (4.4-1, sha256-checked from the Arch archive)
instead of taking whatever the repos ship that day; the build fails
clearly when Arch's Python moves on. Bump steps in `iso/README.md`.
- On-box UI polish: sticky top nav, bordered cards and status tiles, app
tile hover lift, brand-red section markers; animations respect
`prefers-reduced-motion`.
## [26.20-alpha] - 2026-08-25 ## [26.20-alpha] - 2026-08-25
@ -623,7 +659,8 @@ 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.20-alpha...HEAD [Unreleased]: https://forgejo.sourcegate.online/daniel/furtka/compare/26.21-alpha...HEAD
[26.21-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.21-alpha
[26.20-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.20-alpha [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.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.18-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.18-alpha

View file

@ -1,3 +1,5 @@
<p><img src="docs/brand/furtka-lockup.svg" alt="Furtka" height="56"></p>
# Furtka # Furtka
**Open-source home server OS — simple enough for everyone.** · [furtka.org](https://furtka.org) **Open-source home server OS — simple enough for everyone.** · [furtka.org](https://furtka.org)
@ -54,7 +56,7 @@ Furtka aims to be:
| Identity provider | Authentik | Bundled SSO from day one — every app template auto-wires to it at install | | Identity provider | Authentik | Bundled SSO from day one — every app template auto-wires to it at install |
| Managed gateway DNS | NS delegation to `ns1.furtka.org` | User delegates once at registrar; we handle wildcard cert + subdomain creation | | Managed gateway DNS | NS delegation to `ns1.furtka.org` | User delegates once at registrar; we handle wildcard cert + subdomain creation |
| Local HTTPS | Local CA | One-click CA install → green padlock on every service, no browser warnings | | Local HTTPS | Local CA | One-click CA install → green padlock on every service, no browser warnings |
| Gateway | Flexible | Own reverse proxy OR managed through our infrastructure | | Gateway | Traefik + wg-easy ([furtka-gateway](https://forgejo.sourcegate.online/robert/furtka-gateway)) | Decided 2026-10-23. Self-hosted on any VPS with a public IPv4 via `deploy.sh`, or managed through our infrastructure later. Boxes dial out over WireGuard, so no port forwarding at home. |
| UI approach | UI-first | Design the simplest possible UI, then build everything to match | | UI approach | UI-first | Design the simplest possible UI, then build everything to match |
## Landscape (Existing Projects) ## Landscape (Existing Projects)
@ -120,10 +122,13 @@ None of these nail the "your dad can set this up" experience. The installer wiza
- [x] **Phase 2 Furtka self-update** — `/settings` → Check → Update now. Downloads signed tarball (SHA256), stages, atomic symlink flip, reloads Caddy, daemon-reload, restarts services, health-checks the new api with auto-rollback on failure. CLI: `furtka update [--check]` + `furtka rollback`. Validated end-to-end on VM 2026-04-16 (`26.0-alpha` → `26.3-alpha` → rollback → reboot). - [x] **Phase 2 Furtka self-update** — `/settings` → Check → Update now. Downloads signed tarball (SHA256), stages, atomic symlink flip, reloads Caddy, daemon-reload, restarts services, health-checks the new api with auto-rollback on failure. CLI: `furtka update [--check]` + `furtka rollback`. Validated end-to-end on VM 2026-04-16 (`26.0-alpha` → `26.3-alpha` → rollback → reboot).
- [x] **Local HTTPS Phase 1** — Caddy `tls internal` on `:443` is fully opt-in via the `/settings` toggle (26.15-alpha); fresh installs stay HTTP-only so a half-trusted cert chain can't lock the user out. Per-box root CA generated on first enable, `rootCA.crt` downloadable from `/settings`, per-OS install guide at `/https-install/`. The "force HTTPS" sub-toggle still only appears once the current browser already trusts the cert. - [x] **Local HTTPS Phase 1** — Caddy `tls internal` on `:443` is fully opt-in via the `/settings` toggle (26.15-alpha); fresh installs stay HTTP-only so a half-trusted cert chain can't lock the user out. Per-box root CA generated on first enable, `rootCA.crt` downloadable from `/settings`, per-OS install guide at `/https-install/`. The "force HTTPS" sub-toggle still only appears once the current browser already trusts the cert.
- [x] **Post-build smoke VM on Proxmox** — `.forgejo/workflows/build-iso.yml` hands the freshly built ISO to `scripts/smoke-vm.sh`, which boots it in a throwaway VM on `pollux` (192.168.178.165) and curls the webinstaller on `:5000`. VMID range 9000–9099, last 5 kept. Green end-to-end since 26.4-alpha. - [x] **Post-build smoke VM on Proxmox** — `.forgejo/workflows/build-iso.yml` hands the freshly built ISO to `scripts/smoke-vm.sh`, which boots it in a throwaway VM on `pollux` (192.168.178.165) and curls the webinstaller on `:5000`. VMID range 9000–9099, last 5 kept. Green end-to-end since 26.4-alpha.
- [x] **Furtka Gateway (Access Gate)** — boxes behind NAT/CGNAT register with a [furtka-gateway](https://forgejo.sourcegate.online/robert/furtka-gateway) server (Traefik + wg-easy + control plane) over a WireGuard tunnel; Settings → *Furtka Gateway* connects, Apps → ⋯ → *Expose to Internet* publishes an app under its own hostname with Let's Encrypt. Only apps with `internet.viable: true` in the manifest can be exposed. Shipped in `26.21-alpha`; first gateway running at `gw.furtka.org` (Hetzner, 2026-10-23).
- [x] **`26.21-alpha`** — security fixes (app-name validation, `.env` injection, fail-closed `users.json`, API hardening), installer fixes, Mark M logo, furtka.org/download, on-box UI polish. See [CHANGELOG](CHANGELOG.md).
- [ ] Gateway dogfooding on real hardware — two old laptops as boxes, one per brother, both behind CGNAT
- [ ] Installer wizard screens S3–S7 — per-device purpose, network, domain, SSL, diagnostic. S5/S6 blocked on managed-gateway DNS infra not yet built. - [ ] Installer wizard screens S3–S7 — per-device purpose, network, domain, SSL, diagnostic. S5/S6 blocked on managed-gateway DNS infra not yet built.
- [ ] Local HTTPS Phase 2 — dedicated local CA (not Caddy's `tls internal`), streamlined one-click install across Win/Mac/Linux/Android, and HTTPS on the live-installer wizard (`https://proksi.local:5000`). - [ ] Local HTTPS Phase 2 — dedicated local CA (not Caddy's `tls internal`), streamlined one-click install across Win/Mac/Linux/Android, and HTTPS on the live-installer wizard (`https://proksi.local:5000`).
- [ ] Caddy + Authentik wired into first-boot bootstrap - [ ] Caddy + Authentik wired into first-boot bootstrap
- [ ] Managed gateway infrastructure — `ns1/ns2.furtka.org` + DNS-01 wildcard automation - [ ] Managed gateway DNS — `ns1/ns2.furtka.org` + DNS-01 wildcard automation (the gateway itself works today with your own domain and per-app or wildcard A records)
- [ ] First containerized service (Nextcloud?) with auto-SSO + auto-subdomain - [ ] First containerized service (Nextcloud?) with auto-SSO + auto-subdomain
- [ ] Competitor hands-on testing on Proxmox — validate findings from docs/competitors.md - [ ] Competitor hands-on testing on Proxmox — validate findings from docs/competitors.md
- [ ] UI mockups / drafts (Robert) - [ ] UI mockups / drafts (Robert)

View file

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

View file

@ -23,7 +23,8 @@
"git", "git",
"htop", "htop",
"curl", "curl",
"openssh" "openssh",
"wireguard-tools"
], ],
"profile": { "profile": {

View file

@ -13,6 +13,17 @@ 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) mapfile -t ips < <(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1)
new=$( 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

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

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

After

Width:  |  Height:  |  Size: 316 B

View file

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

View file

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

View file

@ -2,6 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<title>Settings · Furtka</title> <title>Settings · Furtka</title>
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="/style.css"> <link rel="stylesheet" href="/style.css">
@ -80,6 +81,45 @@
</div> </div>
</section> </section>
<section>
<h2>Furtka Gateway</h2>
<div class="card">
<p class="lede">
Connect this box to a furtka-gateway to expose individual apps to the
internet over a WireGuard tunnel. Apps stay private until you explicitly
publish one from the <a href="/apps">Apps</a> page — connecting here only
opens the tunnel, nothing is exposed automatically.
</p>
<div id="gateway-disconnected" hidden>
<div class="field">
<label for="gw-url">Gateway URL</label>
<input type="text" id="gw-url" placeholder="https://gw.example.com">
</div>
<div class="field">
<label for="gw-token">Registration token</label>
<input type="password" id="gw-token" placeholder="from the gateway operator">
</div>
<div class="field">
<label for="gw-name">Box name</label>
<input type="text" id="gw-name" placeholder="my-box">
</div>
<div class="update-actions">
<button id="gateway-connect-btn">Connect</button>
</div>
</div>
<div id="gateway-connected" hidden>
<dl class="kv">
<dt>Gateway</dt><dd id="gw-connected-url">—</dd>
<dt>Tunnel</dt><dd id="gw-tunnel-status">—</dd>
</dl>
<div class="update-actions">
<button class="danger" id="gateway-disconnect-btn">Disconnect</button>
</div>
</div>
<p id="gateway-status" class="hint"></p>
</div>
</section>
<section> <section>
<h2>Appearance</h2> <h2>Appearance</h2>
<div class="card"> <div class="card">
@ -112,7 +152,6 @@
<a href="https://furtka.org/#planned">Change hostname</a> <a href="https://furtka.org/#planned">Change hostname</a>
<a href="https://furtka.org/#planned">Backup</a> <a href="https://furtka.org/#planned">Backup</a>
<a href="https://furtka.org/#planned">User accounts</a> <a href="https://furtka.org/#planned">User accounts</a>
<a href="https://furtka.org/#planned">Remote access</a>
</div> </div>
</section> </section>
@ -349,6 +388,127 @@
await refreshHttpsStatus(); await refreshHttpsStatus();
})(); })();
// --- Furtka Gateway -------------------------------------------------
const CONNECT_STAGE_LABELS = {
registering: 'Registering with the gateway…',
starting_tunnel: 'Starting the WireGuard tunnel…',
done: 'Connected.',
};
const gatewayDisconnectedEl = document.getElementById('gateway-disconnected');
const gatewayConnectedEl = document.getElementById('gateway-connected');
const gwConnectedUrlEl = document.getElementById('gw-connected-url');
const gwTunnelStatusEl = document.getElementById('gw-tunnel-status');
const gatewayStatusEl = document.getElementById('gateway-status');
const gatewayConnectBtn = document.getElementById('gateway-connect-btn');
const gatewayDisconnectBtn = document.getElementById('gateway-disconnect-btn');
let gatewayPollHandle = null;
function setGatewayStatus(msg, isError = false) {
gatewayStatusEl.textContent = msg;
gatewayStatusEl.style.color = isError ? 'var(--danger)' : 'var(--muted)';
}
function describeTunnel(tunnel) {
if (!tunnel || !tunnel.configured) return 'not configured';
return tunnel.latest_handshake ? `up (last handshake ${tunnel.latest_handshake})` : 'configured, no handshake yet';
}
async function refreshGatewayStatus() {
try {
const r = await fetch('/api/gateway/status', { cache: 'no-store' });
// Sessions are in-memory, so a furtka-api restart logs us
// out. Both panels start hidden — never claim "not
// connected" when we simply couldn't ask.
if (r.status === 401) {
window.location.href = '/login';
return;
}
if (!r.ok) return;
const s = await r.json();
gatewayDisconnectedEl.hidden = !!s.connected;
gatewayConnectedEl.hidden = !s.connected;
if (s.connected) {
gwConnectedUrlEl.textContent = s.gateway_url;
gwTunnelStatusEl.textContent = describeTunnel(s.tunnel);
}
} catch (e) {
/* next refresh will retry */
}
}
refreshGatewayStatus();
gatewayConnectBtn.addEventListener('click', async () => {
const gateway_url = document.getElementById('gw-url').value.trim();
const registration_token = document.getElementById('gw-token').value;
const box_name = document.getElementById('gw-name').value.trim();
if (!gateway_url || !registration_token || !box_name) {
setGatewayStatus('Gateway URL, registration token, and box name are all required.', true);
return;
}
gatewayConnectBtn.disabled = true;
setGatewayStatus('Connecting…');
try {
const r = await fetch('/api/gateway/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gateway_url, registration_token, box_name }),
});
const data = await r.json();
if (!r.ok) {
setGatewayStatus(data.error || `HTTP ${r.status}`, true);
gatewayConnectBtn.disabled = false;
return;
}
gatewayPollHandle = setInterval(pollGatewayConnectState, 2000);
} catch (e) {
setGatewayStatus(`Network error: ${e.message}`, true);
gatewayConnectBtn.disabled = false;
}
});
async function pollGatewayConnectState() {
try {
const r = await fetch('/api/gateway/connect/status', { cache: 'no-store' });
if (!r.ok) return;
const s = await r.json();
const label = CONNECT_STAGE_LABELS[s.stage] || `Stage: ${s.stage}`;
setGatewayStatus(label, s.stage === 'error');
if (s.stage === 'done') {
clearInterval(gatewayPollHandle);
gatewayConnectBtn.disabled = false;
await refreshGatewayStatus();
} else if (s.stage === 'error') {
clearInterval(gatewayPollHandle);
gatewayConnectBtn.disabled = false;
if (s.error) setGatewayStatus(`${label} — ${s.error}`, true);
}
} catch (e) {
/* keep polling; transient blip expected */
}
}
gatewayDisconnectBtn.addEventListener('click', async () => {
if (!confirm('Disconnect from the gateway? Any apps currently exposed to the internet will stop being reachable.')) return;
gatewayDisconnectBtn.disabled = true;
setGatewayStatus('Disconnecting…');
try {
const r = await fetch('/api/gateway/disconnect', { method: 'POST' });
const data = await r.json();
if (!r.ok) {
setGatewayStatus(data.error || `HTTP ${r.status}`, true);
return;
}
setGatewayStatus('Disconnected.');
await refreshGatewayStatus();
} catch (e) {
setGatewayStatus(`Network error: ${e.message}`, true);
} finally {
gatewayDisconnectBtn.disabled = false;
}
});
async function pollUpdateState() { async function pollUpdateState() {
try { try {
const r = await fetch('/update-state.json', { cache: 'no-store' }); const r = await fetch('/update-state.json', { cache: 'no-store' });

View file

@ -21,7 +21,13 @@
--r-pill: 999px; --r-pill: 999px;
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.3); --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-lift: 0 8px 24px rgba(0, 0, 0, 0.35);
--ring: 0 0 0 2px var(--accent); --ring: 0 0 0 2px var(--accent);
/* Brand red from the logo, used only for small highlights. */
--brand: #ff6b56;
--glow-a: rgba(255, 107, 86, 0.10);
--glow-b: rgba(110, 231, 183, 0.08);
--nav-bg: rgba(15, 17, 21, 0.72);
} }
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
@ -38,6 +44,11 @@
--warn-fg: #5a2a10; --warn-fg: #5a2a10;
--danger: #c03a28; --danger: #c03a28;
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08); --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08);
--shadow-lift: 0 8px 24px rgba(0, 0, 0, 0.10);
--brand: #c03a28;
--glow-a: rgba(192, 58, 40, 0.07);
--glow-b: rgba(15, 138, 95, 0.06);
--nav-bg: rgba(247, 246, 243, 0.8);
} }
} }
@ -45,9 +56,16 @@
body { body {
margin: 0; margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: var(--bg); /* Two soft glows at the top (logo red, accent green) give the page some
depth without competing with the content. */
background:
radial-gradient(60rem 28rem at 15% -10%, var(--glow-a), transparent 70%),
radial-gradient(50rem 24rem at 95% -5%, var(--glow-b), transparent 70%),
var(--bg);
background-attachment: fixed;
color: var(--fg); color: var(--fg);
line-height: 1.5; line-height: 1.5;
-webkit-font-smoothing: antialiased;
} }
/* Shared page container — both landing and /apps wrap content in /* Shared page container — both landing and /apps wrap content in
@ -59,9 +77,16 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding-bottom: 1.25rem; padding: 0.85rem 0;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
margin-bottom: 2rem; margin-bottom: 2rem;
/* Stays in reach while scrolling long pages like /settings. */
position: sticky;
top: 0;
z-index: 20;
background: var(--nav-bg);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
} }
.brand { .brand {
font-weight: 700; font-weight: 700;
@ -73,13 +98,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 {
@ -87,30 +116,52 @@ body {
text-decoration: none; text-decoration: none;
font-size: 0.9rem; font-size: 0.9rem;
padding: 0.35rem 0.75rem; padding: 0.35rem 0.75rem;
border-radius: var(--r-sm); border-radius: var(--r-pill);
transition: color 120ms, background 120ms;
} }
.nav-links a:hover { color: var(--fg); } .nav-links a:hover { color: var(--fg); background: var(--card); }
.nav-links a[aria-current="page"] { .nav-links a[aria-current="page"] {
color: var(--fg); color: var(--fg);
background: var(--accent-soft); background: var(--accent-soft);
} }
/* -- Landing page ---------------------------------------------- */ /* -- Landing page ---------------------------------------------- */
header h1 { margin: 0 0 0.5rem; font-size: 2.5rem; } header h1 {
margin: 0 0 0.5rem;
font-size: clamp(2rem, 6vw, 2.75rem);
letter-spacing: -0.02em;
line-height: 1.1;
background: linear-gradient(100deg, var(--fg) 40%, var(--accent));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.lead { font-size: 1.25rem; color: var(--muted); margin: 0 0 0.25rem; } .lead { font-size: 1.25rem; color: var(--muted); margin: 0 0 0.25rem; }
.host { color: var(--muted); margin: 0 0 3rem; } .host { color: var(--muted); margin: 0 0 3rem; }
.host code { .host code {
background: var(--card); background: var(--accent-soft);
padding: 0.15rem 0.5rem; padding: 0.2rem 0.65rem;
border-radius: var(--r-sm); border-radius: var(--r-pill);
color: var(--accent); color: var(--accent);
font-size: 0.9em;
} }
section h2 { section h2 {
font-size: 1.1rem; font-size: 0.85rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.1em; letter-spacing: 0.12em;
color: var(--muted); color: var(--muted);
margin: 2rem 0 1rem; margin: 2.5rem 0 1rem;
display: flex;
align-items: center;
gap: 0.6rem;
}
/* Small brand-red marker in front of each landing-page section title. */
section h2::before {
content: "";
width: 0.45rem;
height: 0.45rem;
border-radius: var(--r-pill);
background: var(--brand);
} }
.tiles { .tiles {
display: grid; display: grid;
@ -120,9 +171,21 @@ section h2 {
.tile { .tile {
background: var(--card); background: var(--card);
padding: 1.25rem; padding: 1.25rem;
border-radius: var(--r-md); border: 1px solid var(--border);
border-radius: var(--r-lg);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: relative;
overflow: hidden;
}
/* Thin accent line along the top edge of each status tile. */
.tile::before {
content: "";
position: absolute;
inset: 0 0 auto 0;
height: 3px;
background: linear-gradient(90deg, var(--accent), transparent);
opacity: 0.7;
} }
.tile .label { .tile .label {
font-size: 0.8rem; font-size: 0.8rem;
@ -130,7 +193,12 @@ section h2 {
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
} }
.tile .value { font-size: 1.25rem; margin-top: 0.5rem; } .tile .value {
font-size: 1.35rem;
font-weight: 600;
margin-top: 0.5rem;
font-variant-numeric: tabular-nums;
}
.updated { font-size: 0.85rem; color: var(--muted); margin-top: 1rem; } .updated { font-size: 0.85rem; color: var(--muted); margin-top: 1rem; }
.soon { .soon {
background: var(--card); background: var(--card);
@ -168,7 +236,9 @@ h2 {
.app { .app {
background: var(--card); background: var(--card);
padding: 1rem; padding: 1rem;
border-radius: var(--r-md); border: 1px solid var(--border);
border-radius: var(--r-lg);
transition: border-color 120ms;
margin: 0.5rem 0; margin: 0.5rem 0;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -215,13 +285,69 @@ button, .btn {
text-decoration: none; text-decoration: none;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
transition: filter 120ms, transform 80ms;
} }
button:hover:not(:disabled), .btn:hover { filter: brightness(1.08); }
button:active:not(:disabled), .btn:active { transform: translateY(1px); }
button.secondary, .btn.secondary { button.secondary, .btn.secondary {
background: var(--card); background: var(--card);
color: var(--fg); color: var(--fg);
border: 1px solid var(--border); border: 1px solid var(--border);
} }
button.danger { background: var(--danger); color: #fff; } button.danger { background: var(--danger); color: #fff; }
/* Public-URL line under an app's description, shown while the app is
exposed through furtka-gateway. */
.public-link {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.25rem;
font-size: 0.85rem;
color: var(--accent);
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.public-link:hover { text-decoration: underline; }
.public-link .dot {
width: 0.5rem;
height: 0.5rem;
border-radius: var(--r-pill);
background: var(--accent);
flex-shrink: 0;
}
/* "⋯" overflow menu on an installed app row. */
details.menu { position: relative; }
details.menu > summary { list-style: none; padding: 0.5rem 0.75rem; }
details.menu > summary::-webkit-details-marker { display: none; }
.menu-list {
position: absolute;
right: 0;
top: calc(100% + 0.25rem);
z-index: 10;
min-width: 11rem;
padding: 0.25rem;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--r-md);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
display: flex;
flex-direction: column;
}
.menu-list button {
background: none;
color: var(--fg);
font-weight: 500;
width: 100%;
justify-content: flex-start;
}
.menu-list button:hover { background: var(--card-hover); }
.menu-list button.danger { background: none; color: var(--danger); }
.menu-list button.danger:hover { background: var(--card-hover); }
.menu-list hr { border: none; border-top: 1px solid var(--border); margin: 0.25rem 0; }
button:disabled { opacity: 0.5; cursor: wait; } button:disabled { opacity: 0.5; cursor: wait; }
button:focus-visible, .btn:focus-visible { outline: none; box-shadow: var(--ring); } button:focus-visible, .btn:focus-visible { outline: none; box-shadow: var(--ring); }
.empty { color: var(--muted); font-style: italic; padding: 0.5rem 0; } .empty { color: var(--muted); font-style: italic; padding: 0.5rem 0; }
@ -394,7 +520,8 @@ details.log-details[open] > summary { color: var(--fg); }
.card { .card {
background: var(--card); background: var(--card);
padding: 1.25rem; padding: 1.25rem;
border-radius: var(--r-md); border: 1px solid var(--border);
border-radius: var(--r-lg);
box-shadow: var(--shadow-card); box-shadow: var(--shadow-card);
} }
.card + .card { margin-top: 1rem; } .card + .card { margin-top: 1rem; }
@ -449,9 +576,9 @@ details.log-details[open] > summary { color: var(--fg); }
gap: 0.75rem; gap: 0.75rem;
} }
.app-tile { .app-tile {
background: var(--bg); background: var(--card);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--r-md); border-radius: var(--r-lg);
padding: 1rem; padding: 1rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -459,12 +586,20 @@ details.log-details[open] > summary { color: var(--fg); }
align-items: flex-start; align-items: flex-start;
text-decoration: none; text-decoration: none;
color: var(--fg); color: var(--fg);
transition: border-color 120ms, background 120ms; transition: border-color 120ms, background 120ms, transform 120ms, box-shadow 120ms;
}
.app-tile:hover {
border-color: var(--accent);
background: var(--card-hover);
transform: translateY(-2px);
box-shadow: var(--shadow-lift);
} }
.app-tile:hover { border-color: var(--accent); background: var(--card-hover); }
.app-tile .icon { .app-tile .icon {
width: 40px; width: 48px;
height: 40px; height: 48px;
padding: 6px;
border-radius: var(--r-md);
background: var(--accent-soft);
color: var(--accent); color: var(--accent);
display: flex; display: flex;
align-items: center; align-items: center;
@ -490,3 +625,15 @@ 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; }
.app:hover { border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition: none !important; }
.app-tile:hover { transform: none; }
}
@media (prefers-color-scheme: light) {
.brand::before { background: #c03a28; }
}

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

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

View file

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

View file

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

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

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

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

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

After

Width:  |  Height:  |  Size: 224 B

View file

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

After

Width:  |  Height:  |  Size: 236 B

View file

@ -73,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">
@ -369,6 +370,20 @@ async function refresh() {
const openBtn = openHref const openBtn = openHref
? `<a class="btn" href="${esc(openHref)}" target="_blank" rel="noopener">Open</a>` ? `<a class="btn" href="${esc(openHref)}" target="_blank" rel="noopener">Open</a>`
: ''; : '';
// Both filled in async by loadInternetSlots() after this markup
// lands — the current publish state needs its own fetch per app
// (/api/apps/<name>/internet), so it can't be known at row-build
// time. The status line sits under the description (public URL,
// when exposed); the toggle lives in the "⋯" menu.
const viable = a.internet && a.internet.viable;
const internetStatus = viable
? `<span class="internet-status" data-name="${esc(a.name)}"></span>`
: '';
const internetItem = viable
? `<span class="internet-item" data-name="${esc(a.name)}"></span>`
: '';
// Only Open stays visible; everything else goes in a <details>
// menu so the description isn't squeezed by a row of buttons.
return ` return `
<div class="app"> <div class="app">
<div class="left"> <div class="left">
@ -376,14 +391,22 @@ async function refresh() {
<div class="meta"> <div class="meta">
<span class="name">${esc(a.display_name || a.name)} <small>${esc(a.version || '')}</small></span> <span class="name">${esc(a.display_name || a.name)} <small>${esc(a.version || '')}</small></span>
<span class="desc">${esc(a.description || a.error || '')}</span> <span class="desc">${esc(a.description || a.error || '')}</span>
${internetStatus}
</div> </div>
</div> </div>
<div class="buttons"> <div class="buttons">
${openBtn} ${openBtn}
${hasSettings ? `<button data-op="edit" data-name="${esc(a.name)}">Settings</button>` : ''} <details class="menu">
<button class="secondary" data-op="update" data-name="${esc(a.name)}">Update</button> <summary class="btn secondary" aria-label="More actions for ${esc(a.display_name || a.name)}">⋯</summary>
<button class="secondary" data-op="reinstall" data-name="${esc(a.name)}">Reinstall</button> <div class="menu-list">
<button class="danger" data-op="remove" data-name="${esc(a.name)}">Remove</button> ${hasSettings ? `<button data-op="edit" data-name="${esc(a.name)}">Settings</button>` : ''}
${internetItem}
<button data-op="update" data-name="${esc(a.name)}">Update</button>
<button data-op="reinstall" data-name="${esc(a.name)}">Reinstall</button>
<hr>
<button class="danger" data-op="remove" data-name="${esc(a.name)}">Remove</button>
</div>
</details>
</div> </div>
</div>`; </div>`;
}).join('') }).join('')
@ -404,6 +427,78 @@ async function refresh() {
for (const btn of document.querySelectorAll('button[data-op]')) { for (const btn of document.querySelectorAll('button[data-op]')) {
btn.addEventListener('click', () => handleButton(btn.dataset.op, btn.dataset.name, btn)); btn.addEventListener('click', () => handleButton(btn.dataset.op, btn.dataset.name, btn));
} }
loadInternetSlots();
}
// Populates each installed internet-viable app's public-URL status line
// and its "Expose to Internet" / "Stop exposing" menu item. Separate from
// the main refresh() render because the current publish state needs its
// own per-app fetch — the /api/apps list only carries whether an app is
// *capable* of being exposed, not whether it currently is.
async function loadInternetSlots() {
for (const item of document.querySelectorAll('.internet-item')) {
const name = item.dataset.name;
const status = document.querySelector(`.internet-status[data-name="${CSS.escape(name)}"]`);
try {
const r = await fetch(`/api/apps/${encodeURIComponent(name)}/internet`);
if (!r.ok) continue;
renderInternetSlot(item, status, name, await r.json());
} catch (e) {
// Leave the slots empty — next refresh() retries.
}
}
}
function renderInternetSlot(item, status, name, data) {
if (data.enabled) {
const host = data.public_url.replace(/^https?:\\/\\//, '').replace(/\\/$/, '');
if (status) {
status.innerHTML =
`<a class="public-link" href="${esc(data.public_url)}" target="_blank" rel="noopener">` +
`<span class="dot"></span>${esc(host)} ↗</a>`;
}
item.innerHTML = `<button data-internet="${esc(name)}">Stop exposing</button>`;
} else {
if (status) status.innerHTML = '';
item.innerHTML = `<button data-internet="${esc(name)}">Expose to Internet</button>`;
}
item.querySelector('[data-internet]').addEventListener('click', () => handleInternetToggle(name, !!data.enabled));
}
// <details> menus don't close on an outside click by themselves.
document.addEventListener('click', (e) => {
for (const menu of document.querySelectorAll('details.menu[open]')) {
if (!menu.contains(e.target)) menu.removeAttribute('open');
}
});
async function handleInternetToggle(name, currentlyEnabled) {
const logEl = document.getElementById('log');
if (currentlyEnabled) {
if (!confirm(`Stop exposing ${name} to the internet?`)) return;
}
let subdomain = null;
if (!currentlyEnabled) {
subdomain = prompt(
`Public hostname for ${name} — needs its own DNS record already pointing at your gateway:`
);
if (!subdomain) return;
}
try {
const r = await fetch(`/api/apps/${encodeURIComponent(name)}/internet`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(currentlyEnabled ? { enabled: false } : { enabled: true, subdomain }),
});
const data = await r.json();
if (!r.ok) {
logEl.textContent = `[internet ${name}] HTTP ${r.status}\\n` + JSON.stringify(data, null, 2);
return;
}
await refresh();
} catch (e) {
logEl.textContent = `[internet ${name}] Network error: ${e.message}`;
}
} }
async function handleButton(op, name, btn) { async function handleButton(op, name, btn) {
@ -557,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)">
@ -680,6 +777,7 @@ def _manifest_summary(m, app_dir=None):
{"app": req.app, "on_install": req.on_install, "on_start": req.on_start} {"app": req.app, "on_install": req.on_install, "on_start": req.on_start}
for req in m.requires for req in m.requires
], ],
"internet": {"viable": m.internet.viable, "port": m.internet.port},
} }
@ -1155,6 +1253,208 @@ def _do_catalog_status():
} }
def _do_gateway_status():
"""This box's furtka-gateway connection + tunnel state.
No connection at all is the common case (most boxes never connect to a
gateway) — always 200s with connected=False rather than 404ing.
"""
from furtka import gateway, wireguard
config = gateway.load_config()
if config is None:
return 200, {"connected": False}
return 200, {
"connected": True,
"gateway_url": config["gateway_url"],
"box_id": config["box_id"],
"tunnel": wireguard.status(),
}
def _do_gateway_connect(payload):
"""Kick off gateway registration + tunnel setup detached from this process.
Fast validation happens here so a bad request comes back as an
immediate 4xx. The slow part — a network call to a possibly slow or
unreachable gateway, then `systemctl enable --now wg-quick@...` — is
dispatched as a background systemd-run unit, the same idiom
_do_install uses for its docker-facing phase, so it can't tie up the
API thread. Client polls /api/gateway/connect/status.
Note: registration_token travels as a systemd-run argv entry, briefly
visible to anything on this box that can read `ps`/`systemctl show`
while the transient unit runs. Accepted here the same way furtka
already treats local-box access as a trusted boundary (see _do_power's
"anyone on the LAN can reboot" posture) — not something a home/single-
tenant box's threat model treats as a meaningful new exposure.
"""
import subprocess
from furtka import gateway
gateway_url = payload.get("gateway_url")
registration_token = payload.get("registration_token")
box_name = payload.get("box_name")
if not gateway_url or not registration_token or not box_name:
return 400, {"error": "gateway_url, registration_token, and box_name are all required"}
try:
fh = gateway.acquire_lock()
except gateway.GatewayError as e:
return 409, {"error": str(e)}
try:
# Initial state so the UI has something to show between this
# response and the background job's first write.
gateway.write_state("registering")
finally:
fh.close()
try:
subprocess.run(
[
"systemd-run",
"--unit=furtka-gateway-connect-api",
"--no-block",
"--collect",
"/usr/local/bin/furtka",
"gateway",
"connect-bg",
gateway_url,
registration_token,
box_name,
],
check=True,
capture_output=True,
text=True,
)
except FileNotFoundError:
gateway.write_state("error", error="systemd-run not available")
return 502, {"error": "systemd-run not available"}
except subprocess.CalledProcessError as e:
err = (e.stderr or e.stdout or "").strip()
gateway.write_state("error", error=f"dispatch failed: {err}")
return 502, {"error": f"systemd-run failed: {err}"}
return 202, {"status": "dispatched", "unit": "furtka-gateway-connect-api"}
def _do_gateway_connect_status():
from furtka import gateway
return 200, gateway.read_state()
def _do_gateway_disconnect():
"""Tear down the tunnel and deregister from the gateway.
Synchronous — deregistering is one HTTP call plus a systemctl disable,
not a slow docker pull, so this doesn't need the async+poll dance
(same posture as _do_update_settings).
"""
from furtka import gateway, wireguard
if gateway.load_config() is None:
return 200, {"status": "not connected"}
try:
gateway.deregister()
except gateway.GatewayError as e:
return 502, {"error": str(e)}
try:
wireguard.set_tunnel_enabled(False)
except wireguard.WireguardError as e:
return 502, {"error": str(e)}
return 200, {"status": "disconnected"}
def _internet_routes() -> dict:
from furtka.paths import internet_routes_file
path = internet_routes_file()
if not path.is_file():
return {}
try:
return json.loads(path.read_text())
except json.JSONDecodeError:
return {}
def _save_internet_routes(routes: dict) -> None:
from furtka.paths import internet_routes_file
path = internet_routes_file()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(routes, indent=2))
tmp.chmod(0o600)
tmp.replace(path)
def _do_get_internet(name):
"""Current internet-exposure state for one installed app.
404s for apps that aren't installed or aren't internet.viable — the UI
only ever renders the toggle for viable, installed apps, so either
case means this request shouldn't have been made.
"""
m, _values, installed = _load_manifest_for(name)
if m is None or not installed:
return 404, {"error": f"{name!r} is not installed"}
if not m.internet.viable:
return 404, {"error": f"{name!r} is not internet-viable"}
entry = _internet_routes().get(name)
if entry is None:
return 200, {"enabled": False}
return 200, {"enabled": True, "public_url": entry.get("public_url")}
def _do_set_internet(name, payload):
"""Publish or unpublish an app through the connected gateway.
Synchronous — a plain HTTP call to the gateway's control plane, no
image pull involved, same posture as _do_update_settings, not the
async+poll pattern _do_gateway_connect needs.
"""
from furtka import gateway
enabled = payload.get("enabled")
if not isinstance(enabled, bool):
return 400, {"error": "'enabled' (bool) is required"}
m, _values, installed = _load_manifest_for(name)
if m is None or not installed:
return 404, {"error": f"{name!r} is not installed"}
if not m.internet.viable:
return 400, {"error": f"{name!r} is not internet-viable"}
if gateway.load_config() is None:
return 409, {"error": "this box is not connected to a gateway"}
routes = _internet_routes()
if enabled:
subdomain = payload.get("subdomain")
if not subdomain:
return 400, {"error": "'subdomain' is required to publish"}
try:
result = gateway.publish_route(name, subdomain, m.internet.port)
except gateway.GatewayError as e:
return 502, {"error": str(e)}
routes[name] = {"route_id": result["route_id"], "public_url": result["public_url"]}
_save_internet_routes(routes)
return 200, {"enabled": True, "public_url": result["public_url"]}
entry = routes.pop(name, None)
if entry is None:
return 200, {"enabled": False}
try:
gateway.unpublish_route(entry["route_id"])
except gateway.GatewayError as e:
return 502, {"error": str(e)}
_save_internet_routes(routes)
return 200, {"enabled": False}
_POWER_ACTIONS = { _POWER_ACTIONS = {
"reboot": "reboot", "reboot": "reboot",
"poweroff": "poweroff", "poweroff": "poweroff",
@ -1517,6 +1817,12 @@ class _Handler(BaseHTTPRequestHandler):
if self.path == "/api/apps/install/status": if self.path == "/api/apps/install/status":
status, body = _do_install_status() status, body = _do_install_status()
return self._json(status, body) return self._json(status, body)
if self.path == "/api/gateway/status":
status, body = _do_gateway_status()
return self._json(status, body)
if self.path == "/api/gateway/connect/status":
status, body = _do_gateway_connect_status()
return self._json(status, body)
# /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")]
@ -1524,6 +1830,13 @@ class _Handler(BaseHTTPRequestHandler):
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)
# /api/apps/<name>/internet
if self.path.startswith("/api/apps/") and self.path.endswith("/internet"):
name = self.path[len("/api/apps/") : -len("/internet")]
if "/" in name or not name:
return self._json(400, {"error": "invalid app name"})
status, body = _do_get_internet(name)
return self._json(status, body)
self._json(404, {"error": "not found"}) self._json(404, {"error": "not found"})
def _route_post(self): def _route_post(self):
@ -1582,6 +1895,22 @@ class _Handler(BaseHTTPRequestHandler):
status, body = _do_update(name) status, body = _do_update(name)
return self._json(status, body) return self._json(status, body)
# Per-app internet-exposure toggle: /api/apps/<name>/internet
if self.path.startswith("/api/apps/") and self.path.endswith("/internet"):
name = self.path[len("/api/apps/") : -len("/internet")]
if "/" in name or not name:
return self._json(400, {"error": "invalid app name"})
status, body = _do_set_internet(name, payload)
return self._json(status, body)
# This box's furtka-gateway connection: connect / disconnect.
if self.path == "/api/gateway/connect":
status, body = _do_gateway_connect(payload)
return self._json(status, body)
if self.path == "/api/gateway/disconnect":
status, body = _do_gateway_disconnect()
return self._json(status, body)
# Furtka itself: check + apply endpoints (Phase 2 self-update). # Furtka itself: check + apply endpoints (Phase 2 self-update).
if self.path == "/api/furtka/update/check": if self.path == "/api/furtka/update/check":
status, body = _do_furtka_check() status, body = _do_furtka_check()

View file

@ -34,6 +34,7 @@ from __future__ import annotations
import fcntl import fcntl
import json import json
import os import os
import re
import shutil import shutil
import time import time
from dataclasses import dataclass from dataclasses import dataclass
@ -150,6 +151,9 @@ def acquire_lock():
return fh return fh
_PRIVILEGED_RE = re.compile(r"^\s*privileged:\s*true\s*$", re.MULTILINE)
def _validate_staging(staging: Path, expected_version: str) -> None: def _validate_staging(staging: Path, expected_version: str) -> None:
"""Fail hard if the staging tree isn't a well-formed catalog release.""" """Fail hard if the staging tree isn't a well-formed catalog release."""
version_file = staging / _VERSION_FILE version_file = staging / _VERSION_FILE
@ -170,10 +174,23 @@ def _validate_staging(staging: Path, expected_version: str) -> None:
if not manifest_path.exists(): if not manifest_path.exists():
raise CatalogError(f"catalog app {entry.name!r} has no manifest.json") raise CatalogError(f"catalog app {entry.name!r} has no manifest.json")
try: try:
load_manifest(manifest_path, expected_name=entry.name) manifest = load_manifest(manifest_path, expected_name=entry.name)
except ManifestError as e: except ManifestError as e:
raise CatalogError(f"catalog app {entry.name!r}: invalid manifest: {e}") from e raise CatalogError(f"catalog app {entry.name!r}: invalid manifest: {e}") from e
if manifest.internet.viable:
compose_path = entry / "docker-compose.yaml"
if compose_path.exists() and _PRIVILEGED_RE.search(compose_path.read_text()):
# Not a network-mode check — Traefik reaches a host-networked
# container's port exactly like a bridge-published one. The
# real risk of `internet.viable: true` here is a compromised
# internet-facing container also having host device/kernel
# access, which `privileged: true` grants outright.
raise CatalogError(
f"catalog app {entry.name!r}: internet.viable apps must not run "
"privileged (docker-compose.yaml sets privileged: true)"
)
def _atomic_swap(staging: Path) -> None: def _atomic_swap(staging: Path) -> None:
"""Move staging → live catalog, keeping the previous tree as .prev until """Move staging → live catalog, keeping the previous tree as .prev until

View file

@ -46,6 +46,10 @@ def _cmd_app_list(args: argparse.Namespace) -> int:
} }
for req in r.manifest.requires for req in r.manifest.requires
], ],
"internet": {
"viable": r.manifest.internet.viable,
"port": r.manifest.internet.port,
},
} }
if r.manifest if r.manifest
else None, else None,
@ -171,6 +175,25 @@ def _cmd_app_remove(args: argparse.Namespace) -> int:
return 0 return 0
def _cmd_gateway_connect_bg(args: argparse.Namespace) -> int:
"""Register + bring up the tunnel — called by the API via systemd-run.
Internal subcommand; the UI drives this through
`POST /api/gateway/connect`, which does fast input validation
synchronously and dispatches this the same way `app install` dispatches
`app install-bg`.
"""
from furtka import gateway
try:
gateway.run_connect(args.gateway_url, args.registration_token, args.box_name)
except Exception as e:
# run_connect already wrote state="error"; echo for journald.
print(f"gateway connect-bg failed: {e}", file=sys.stderr)
return 1
return 0
def _cmd_serve(args: argparse.Namespace) -> int: def _cmd_serve(args: argparse.Namespace) -> int:
# Imported lazily so `furtka` startup stays cheap when the user only runs # Imported lazily so `furtka` startup stays cheap when the user only runs
# `app list` or `reconcile` (the common case during tests + scripts). # `app list` or `reconcile` (the common case during tests + scripts).
@ -399,6 +422,20 @@ def build_parser() -> argparse.ArgumentParser:
) )
catalog_status.set_defaults(func=_cmd_catalog_status) catalog_status.set_defaults(func=_cmd_catalog_status)
gateway = sub.add_parser("gateway", help="Manage this box's furtka-gateway connection")
gateway_sub = gateway.add_subparsers(dest="subcommand", required=True)
# Internal — called by the HTTP API via systemd-run. Deliberately
# omitted from the help listing, same as `app install-bg`.
gateway_connect_bg = gateway_sub.add_parser(
"connect-bg",
help=argparse.SUPPRESS,
)
gateway_connect_bg.add_argument("gateway_url")
gateway_connect_bg.add_argument("registration_token")
gateway_connect_bg.add_argument("box_name")
gateway_connect_bg.set_defaults(func=_cmd_gateway_connect_bg)
return p return p

246
furtka/gateway.py Normal file
View file

@ -0,0 +1,246 @@
"""Client for a Furtka box registering with (and talking to) a
furtka-gateway.
Talks to the gateway's `/v1/boxes/*` API (see the furtka-gateway repo's
`control_plane/api.py` for the server side) using the same stdlib-only
`urllib.request` convention as `_release_common.py`/`updater.py` — no new
dependency for this.
`register()` persists the result to `paths.gateway_config_file()` (0600,
atomic write — the same tmp-file-then-replace pattern `auth.save_users`
uses) so later calls don't need the box to re-register, and so
`/api/gateway/status` has something to read without holding a live
connection open.
`run_connect()` plus the state/lock helpers below follow the exact shape
of furtka.catalog's own state/lock functions — this module is the
"runner" `furtka gateway connect-bg` calls after `api.py`'s
`POST /api/gateway/connect` dispatches it via systemd-run, the same
async-with-state-file-polling idiom used for install/update/catalog-sync.
"""
from __future__ import annotations
import fcntl
import json
import os
import time
import urllib.error
import urllib.request
from pathlib import Path
from furtka import wireguard
from furtka.paths import gateway_config_file
_CONNECT_STATE = Path(
os.environ.get("FURTKA_GATEWAY_CONNECT_STATE", "/var/lib/furtka/gateway-connect-state.json")
)
_CONNECT_LOCK = Path(
os.environ.get("FURTKA_GATEWAY_CONNECT_LOCK", "/run/furtka/gateway-connect.lock")
)
class GatewayError(Exception):
"""Recoverable failure talking to a furtka-gateway — the caller should 5xx."""
def _request(method: str, url: str, body: dict | None = None, *, token: str | None = None) -> dict:
data = json.dumps(body).encode() if body is not None else None
headers = {}
if data is not None:
headers["Content-Type"] = "application/json"
if token:
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
detail = e.read().decode(errors="replace")
raise GatewayError(f"{method} {url} -> HTTP {e.code}: {detail}") from e
except urllib.error.URLError as e:
raise GatewayError(f"{method} {url}: {e}") from e
def _post(url: str, body: dict, *, token: str | None = None) -> dict:
return _request("POST", url, body, token=token)
def _delete(url: str, *, token: str | None = None) -> dict:
return _request("DELETE", url, token=token)
def _save_config(config: dict) -> None:
path = gateway_config_file()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(config, indent=2) + "\n")
tmp.chmod(0o600)
tmp.replace(path)
def load_config() -> dict | None:
"""Return the persisted {gateway_url, box_id, box_token, wg} config, or
None if this box has never registered with a gateway."""
path = gateway_config_file()
if not path.is_file():
return None
return json.loads(path.read_text())
def register(gateway_url: str, registration_token: str, box_name: str) -> dict:
"""Register this box with a furtka-gateway.
No local keypair is generated here — the gateway's response carries the
full WireGuard interface config, private key included (see
furtka-gateway's `wgeasy.py` module docstring for why stock wg-easy
makes that unavoidable). Persists and returns
{gateway_url, box_id, box_token, wg}.
"""
gateway_url = gateway_url.rstrip("/")
result = _post(
f"{gateway_url}/v1/boxes/register",
{"registration_token": registration_token, "box_name": box_name},
)
config = {
"gateway_url": gateway_url,
"box_id": result["box_id"],
"box_token": result["box_token"],
"wg": result["wg"],
}
_save_config(config)
return config
def rotate_token() -> str:
"""Ask the gateway for a new box token, replacing the stored one."""
config = load_config()
if config is None:
raise GatewayError("not connected to a gateway")
result = _post(
f"{config['gateway_url']}/v1/boxes/{config['box_id']}/rotate-token",
{},
token=config["box_token"],
)
config["box_token"] = result["box_token"]
_save_config(config)
return config["box_token"]
def deregister() -> None:
"""Tell the gateway to forget this box, then drop the local config.
Raises on a failed gateway call rather than silently clearing local
state — a box that can't reach the gateway should stay "connected"
locally so the operation can be retried, instead of losing its
box_token while the gateway's own row (and the wg-easy peer / any
published routes) linger orphaned.
"""
config = load_config()
if config is None:
return
_post(
f"{config['gateway_url']}/v1/boxes/{config['box_id']}/deregister",
{},
token=config["box_token"],
)
gateway_config_file().unlink(missing_ok=True)
def publish_route(app_name: str, subdomain: str, port: int) -> dict:
"""Publish `app_name` at `subdomain` through the connected gateway.
Returns {route_id, public_url}. The gateway independently re-validates
subdomain/port server-side regardless of what's sent here — see
furtka-gateway's routes.py — this call just carries the box owner's
request, it isn't itself the security boundary.
"""
config = load_config()
if config is None:
raise GatewayError("not connected to a gateway")
return _post(
f"{config['gateway_url']}/v1/routes",
{"app_name": app_name, "subdomain": subdomain, "port": port},
token=config["box_token"],
)
def unpublish_route(route_id: str) -> None:
config = load_config()
if config is None:
raise GatewayError("not connected to a gateway")
_delete(f"{config['gateway_url']}/v1/routes/{route_id}", token=config["box_token"])
def state_path() -> Path:
return _CONNECT_STATE
def lock_path() -> Path:
return _CONNECT_LOCK
def write_state(stage: str, **extra) -> None:
"""Atomic JSON state write — same shape as catalog/update-state."""
state_path().parent.mkdir(parents=True, exist_ok=True)
tmp = state_path().with_suffix(".tmp")
payload = {"stage": stage, "updated_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), **extra}
tmp.write_text(json.dumps(payload, indent=2))
tmp.replace(state_path())
def read_state() -> dict:
try:
return json.loads(state_path().read_text())
except (FileNotFoundError, json.JSONDecodeError):
return {}
def acquire_lock():
path = lock_path()
path.parent.mkdir(parents=True, exist_ok=True)
fh = path.open("w")
try:
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as e:
fh.close()
raise GatewayError("another gateway connect is already in progress") from e
return fh
def run_connect(gateway_url: str, registration_token: str, box_name: str) -> None:
"""Register with the gateway, then bring the tunnel up — the slow part
`furtka gateway connect-bg` runs in the background (dispatched via
systemd-run from `POST /api/gateway/connect`), writing state
transitions as it goes so the UI can poll instead of the request
thread blocking on a slow/unreachable gateway.
A registration saved by an earlier attempt whose tunnel failed to come
up is reused rather than registering again — re-registering would leave
the first box (and its wg-easy peer) orphaned on the gateway.
"""
existing = load_config()
if existing is not None:
if existing["gateway_url"] != gateway_url.rstrip("/"):
write_state(
"error",
error=f"already registered with {existing['gateway_url']} — disconnect first",
)
return
config = existing
else:
write_state("registering")
try:
config = register(gateway_url, registration_token, box_name)
except GatewayError as e:
write_state("error", error=str(e))
return
write_state("starting_tunnel")
try:
wireguard.set_tunnel_enabled(True, wg=config["wg"])
except wireguard.WireguardError as e:
write_state("error", error=str(e))
return
write_state("done")

View file

@ -36,7 +36,11 @@ from pathlib import Path
CA_CERT_PATH = Path("/var/lib/caddy/pki/authorities/local/root.crt") CA_CERT_PATH = Path("/var/lib/caddy/pki/authorities/local/root.crt")
SNIPPET_DIR = Path("/etc/caddy/furtka.d") SNIPPET_DIR = Path("/etc/caddy/furtka.d")
REDIRECT_SNIPPET = SNIPPET_DIR / "redirect.caddyfile" REDIRECT_SNIPPET = SNIPPET_DIR / "redirect.caddyfile"
REDIRECT_CONTENT = "redir https://{host}{uri} permanent\n" # Pre-26.21 redirect: permanent (browsers cache a 301 indefinitely, so
# turning HTTPS off left them bouncing to a dead :443) and for every Host —
# including a bare IP, which has no certificate, so devices that reach the
# box by IP (no mDNS on Android / older Windows) couldn't load the UI at all.
LEGACY_REDIRECT_CONTENT = "redir https://{host}{uri} permanent\n"
HTTPS_SNIPPET_DIR = Path("/etc/caddy/furtka-https.d") HTTPS_SNIPPET_DIR = Path("/etc/caddy/furtka-https.d")
HTTPS_SNIPPET = HTTPS_SNIPPET_DIR / "https.caddyfile" HTTPS_SNIPPET = HTTPS_SNIPPET_DIR / "https.caddyfile"
HOSTNAME_FILE = Path("/etc/hostname") HOSTNAME_FILE = Path("/etc/hostname")
@ -62,6 +66,19 @@ def _read_hostname(hostname_file: Path = HOSTNAME_FILE) -> str:
return value or "furtka" return value or "furtka"
def _redirect_snippet_content(hostname: str) -> str:
"""HTTP→HTTPS redirect for the names the HTTPS block has a cert for.
Only those hosts are redirected: an IP or any other name keeps plain
HTTP instead of landing on a TLS handshake that can't succeed. 302 so
switching HTTPS off takes effect in browsers immediately.
"""
return (
f"@furtka_https_host host {hostname}.local {hostname}\n"
"redir @furtka_https_host https://{host}{uri} 302\n"
)
def _https_snippet_content(hostname: str) -> str: def _https_snippet_content(hostname: str) -> str:
"""Caddy site block the HTTPS toggle installs at opt-in. """Caddy site block the HTTPS toggle installs at opt-in.
@ -151,8 +168,9 @@ def set_force_https(
previous_https = https_snippet.read_text() if had_https else None previous_https = https_snippet.read_text() if had_https else None
if enabled: if enabled:
snippet.write_text(REDIRECT_CONTENT) hostname = _read_hostname(hostname_file)
https_snippet.write_text(_https_snippet_content(_read_hostname(hostname_file))) snippet.write_text(_redirect_snippet_content(hostname))
https_snippet.write_text(_https_snippet_content(hostname))
else: else:
if had_redirect: if had_redirect:
snippet.unlink() snippet.unlink()

View file

@ -32,6 +32,21 @@ class Setting:
default: str | None default: str | None
@dataclass(frozen=True)
class Internet:
# Whether this app is safe to expose to the internet through a
# furtka-gateway at all. Only ever a catalog author's self-declaration —
# the gateway independently re-validates the port it's given at publish
# time regardless of what this says (see furtka-gateway's routes.py).
viable: bool
# Which declared `ports` entry is the exposable web UI. Required when
# viable is True — an app declaring several ports (e.g. a DNS service
# exposing both its resolver port and an admin UI port) must say which
# one is actually meant to be reachable from the internet; there is no
# way to infer that from `ports` alone.
port: int | None
@dataclass(frozen=True) @dataclass(frozen=True)
class Requirement: class Requirement:
app: str # name of the required app — must resolve in installed/catalog/bundled app: str # name of the required app — must resolve in installed/catalog/bundled
@ -62,6 +77,7 @@ class Manifest:
# no frontend (CLI-only, background workers) leave this empty. # no frontend (CLI-only, background workers) leave this empty.
open_url: str = "" open_url: str = ""
requires: tuple[Requirement, ...] = field(default_factory=tuple) requires: tuple[Requirement, ...] = field(default_factory=tuple)
internet: Internet = field(default_factory=lambda: Internet(viable=False, port=None))
def volume_name(self, short: str) -> str: def volume_name(self, short: str) -> str:
# Namespace volume names so two apps can each declare e.g. "data" # Namespace volume names so two apps can each declare e.g. "data"
@ -155,6 +171,27 @@ def _parse_requires(raw: object, manifest_path: Path, self_name: str) -> tuple[R
return tuple(out) return tuple(out)
def _parse_internet(raw: object, manifest_path: Path, ports: list[int]) -> Internet:
if raw is None:
return Internet(viable=False, port=None)
if not isinstance(raw, dict):
raise ManifestError(f"{manifest_path}: internet must be an object")
viable = bool(raw.get("viable", False))
port = raw.get("port")
if not viable:
return Internet(viable=False, port=None)
if not isinstance(port, int) or isinstance(port, bool):
raise ManifestError(f"{manifest_path}: internet.port must be an integer when viable")
if port not in ports:
raise ManifestError(
f"{manifest_path}: internet.port {port} must be one of the declared ports {ports}"
)
return Internet(viable=True, port=port)
def load_manifest(path: Path, expected_name: str | None = None) -> Manifest: def load_manifest(path: Path, expected_name: str | None = None) -> Manifest:
"""Parse and validate a manifest.json. """Parse and validate a manifest.json.
@ -192,6 +229,7 @@ def load_manifest(path: Path, expected_name: str | None = None) -> Manifest:
settings = _parse_settings(raw.get("settings"), path) settings = _parse_settings(raw.get("settings"), path)
requires = _parse_requires(raw.get("requires"), path, name) requires = _parse_requires(raw.get("requires"), path, name)
internet = _parse_internet(raw.get("internet"), path, ports)
open_url_raw = raw.get("open_url", "") open_url_raw = raw.get("open_url", "")
if not isinstance(open_url_raw, str): if not isinstance(open_url_raw, str):
@ -209,4 +247,5 @@ def load_manifest(path: Path, expected_name: str | None = None) -> Manifest:
settings=settings, settings=settings,
open_url=open_url_raw, open_url=open_url_raw,
requires=requires, requires=requires,
internet=internet,
) )

View file

@ -20,6 +20,18 @@ DEFAULT_USERS_FILE = Path("/var/lib/furtka/users.json")
# /settings* so those pages pick up the auth-guard. Caddy also serves # /settings* so those pages pick up the auth-guard. Caddy also serves
# /style.css and other assets directly from here for the login page. # /style.css and other assets directly from here for the login page.
DEFAULT_STATIC_WWW = Path("/opt/furtka/current/assets/www") DEFAULT_STATIC_WWW = Path("/opt/furtka/current/assets/www")
# This box's furtka-gateway registration: {gateway_url, box_id, box_token,
# wg: {...}}. Written once by furtka.gateway.register(), 0600 (it carries
# the box's WireGuard private key — see furtka-gateway's wgeasy.py for why
# that's unavoidable with stock wg-easy). Lives under /var/lib/furtka/ so
# self-updates don't stomp it, same as users.json.
DEFAULT_GATEWAY_CONFIG = Path("/var/lib/furtka/gateway.json")
# Which installed apps are currently published through the connected
# gateway, keyed by app name: {app_name: {route_id, subdomain, port}}.
# Separate from gateway.json since this changes per-app-toggle, not per
# gateway-connection — no reason to rewrite the (more sensitive) box
# credentials file on every publish/unpublish.
DEFAULT_INTERNET_ROUTES_FILE = Path("/var/lib/furtka/internet-routes.json")
def apps_dir() -> Path: def apps_dir() -> Path:
@ -44,3 +56,11 @@ def users_file() -> Path:
def static_www_dir() -> Path: def static_www_dir() -> Path:
return Path(os.environ.get("FURTKA_STATIC_WWW", DEFAULT_STATIC_WWW)) return Path(os.environ.get("FURTKA_STATIC_WWW", DEFAULT_STATIC_WWW))
def gateway_config_file() -> Path:
return Path(os.environ.get("FURTKA_GATEWAY_CONFIG", DEFAULT_GATEWAY_CONFIG))
def internet_routes_file() -> Path:
return Path(os.environ.get("FURTKA_INTERNET_ROUTES_FILE", DEFAULT_INTERNET_ROUTES_FILE))

View file

@ -192,6 +192,20 @@ def _maybe_migrate_preserve_https() -> None:
) )
def _maybe_migrate_redirect_snippet() -> None:
"""26.21 migration: swap the old catch-all 301 redirect for the
hostname-scoped 302 one, so boxes reached by IP can load the UI again
and turning HTTPS off stops sticking in browser caches."""
from furtka import https
redirect_snippet = _CADDY_SNIPPET_DIR / "redirect.caddyfile"
if not redirect_snippet.is_file():
return
if redirect_snippet.read_text() != https.LEGACY_REDIRECT_CONTENT:
return
redirect_snippet.write_text(https._redirect_snippet_content(_current_hostname()))
def _refresh_caddyfile(source: Path) -> bool: def _refresh_caddyfile(source: Path) -> bool:
"""Copy the shipped Caddyfile to /etc/caddy/ iff it differs. Returns True """Copy the shipped Caddyfile to /etc/caddy/ iff it differs. Returns True
if the file changed (so caddy needs more than a bare reload). if the file changed (so caddy needs more than a bare reload).
@ -215,6 +229,7 @@ def _refresh_caddyfile(source: Path) -> bool:
# hostname block is gone, so the redirect would 301 to a dead :443. # hostname block is gone, so the redirect would 301 to a dead :443.
# Preserve intent by writing the HTTPS listener snippet too. # Preserve intent by writing the HTTPS listener snippet too.
_maybe_migrate_preserve_https() _maybe_migrate_preserve_https()
_maybe_migrate_redirect_snippet()
rendered = source.read_text().replace(_CADDYFILE_HOSTNAME_MARKER, _current_hostname()) rendered = source.read_text().replace(_CADDYFILE_HOSTNAME_MARKER, _current_hostname())
if _CADDYFILE_LIVE.is_file() and rendered == _CADDYFILE_LIVE.read_text(): if _CADDYFILE_LIVE.is_file() and rendered == _CADDYFILE_LIVE.read_text():
return False return False

160
furtka/wireguard.py Normal file
View file

@ -0,0 +1,160 @@
"""WireGuard tunnel to a connected furtka-gateway.
Structural mirror of furtka.https's snippet-write-plus-reload pattern:
write/remove /etc/wireguard/furtka-gw.conf, then flip the wg-quick@furtka-gw
systemd unit, reverting the conf file if that call fails so a bad config
can't leave the tunnel half-configured. One deliberate difference from
https.py: enabling and disabling are genuinely different systemctl
subcommands here (Caddy only ever needs a single `reload` either way), so
the injectable `reload` callable takes the resolved action ("enable" or
"disable") rather than being a fixed zero-argument call.
This module never generates its own keypair or contacts furtka-gateway
directly — furtka.gateway.register() gets the full WireGuard interface
config (including the private key; see furtka-gateway's wgeasy.py for why
that's unavoidable with stock wg-easy) and hands it to
set_tunnel_enabled() here.
"""
import os
import subprocess
from pathlib import Path
DEFAULT_CONF_PATH = Path("/etc/wireguard/furtka-gw.conf")
INTERFACE = "furtka-gw"
def default_conf_path() -> Path:
return Path(os.environ.get("FURTKA_WIREGUARD_CONF", DEFAULT_CONF_PATH))
class WireguardError(Exception):
"""Recoverable failure from set_tunnel_enabled — the caller should 5xx."""
def _conf_content(wg: dict) -> str:
"""Render a wg-quick .conf from a furtka-gateway registration response's
`wg` block. AllowedIPs here governs what THIS box routes *through* the
tunnel — furtka-gateway hands back a split-tunnel value (its own peer
subnet only), which this module passes through verbatim rather than
widening it, so a compromised gateway can't pull the box's other
traffic through it.
`preshared_key` is only present when the gateway's wg-easy is v15
(which generates one per client by default and enforces it
server-side) — omitted (None/missing) for v14, which has none.
Dropping a present preshared key here would break the handshake
silently, since the server side would still expect it.
"""
preshared_key_line = ""
if wg.get("preshared_key"):
preshared_key_line = f"PresharedKey = {wg['preshared_key']}\n"
return (
"[Interface]\n"
f"PrivateKey = {wg['private_key']}\n"
f"Address = {wg['address']}\n"
"\n"
"[Peer]\n"
f"PublicKey = {wg['server_public_key']}\n"
f"{preshared_key_line}"
f"Endpoint = {wg['endpoint']}\n"
f"AllowedIPs = {wg['allowed_ips']}\n"
"PersistentKeepalive = 25\n"
)
def _default_reload(action: str) -> None:
subprocess.run(
["systemctl", action, "--now", f"wg-quick@{INTERFACE}"],
check=True,
capture_output=True,
text=True,
)
def set_tunnel_enabled(
enabled: bool,
wg: dict | None = None,
conf_path: Path | None = None,
reload=_default_reload,
) -> bool:
"""Bring the tunnel up (writing conf_path from `wg`) or down.
`wg` is required when enabling ({private_key, address, server_public_key,
endpoint, allowed_ips} — furtka.gateway.register()'s return shape),
ignored when disabling. Reverts conf_path to its pre-call state if the
systemctl action fails, so a bad config can't leave the tunnel wedged.
"""
if enabled and wg is None:
raise ValueError("wg config is required to enable the tunnel")
if conf_path is None:
conf_path = default_conf_path()
try:
conf_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
had_conf = conf_path.is_file()
previous = conf_path.read_text() if had_conf else None
if enabled:
conf_path.write_text(_conf_content(wg))
conf_path.chmod(0o600)
elif had_conf:
conf_path.unlink()
except OSError as e:
raise WireguardError(f"could not access {conf_path}: {e}") from e
try:
reload("enable" if enabled else "disable")
except subprocess.CalledProcessError as e:
_revert(conf_path, previous)
msg = (e.stderr or e.stdout or "").strip() or f"exit {e.returncode}"
raise WireguardError(f"systemctl failed: {msg}") from e
except FileNotFoundError as e:
_revert(conf_path, previous)
raise WireguardError(f"systemctl not available: {e}") from e
return enabled
def _revert(conf_path: Path, previous: str | None) -> None:
if previous is None:
try:
conf_path.unlink()
except FileNotFoundError:
pass
else:
conf_path.write_text(previous)
def _default_wg_show() -> str:
result = subprocess.run(
["wg", "show", INTERFACE, "latest-handshakes"],
check=True,
capture_output=True,
text=True,
)
return result.stdout.strip()
def status(conf_path: Path | None = None, wg_show=_default_wg_show) -> dict:
"""Whether the tunnel is configured, and its latest handshake if `wg`
tooling is available (None if not — e.g. wg-tools missing, or the
interface isn't actually up yet right after enabling).
Read-only and best-effort: any OSError stat-ing conf_path (missing
parent dir, an unexpected permission situation) is treated the same as
"not configured" rather than raised — a status check must never 500
the whole request over something this innocuous.
"""
if conf_path is None:
conf_path = default_conf_path()
try:
configured = conf_path.is_file()
except OSError:
configured = False
latest_handshake = None
if configured:
try:
latest_handshake = wg_show()
except (subprocess.CalledProcessError, FileNotFoundError):
latest_handshake = None
return {"configured": configured, "latest_handshake": latest_handshake}

View file

@ -34,6 +34,17 @@ The systemd service runs `flask --app app run --host 0.0.0.0 --port 5000` under
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.
### archinstall is pinned
The webinstaller imports archinstall internals and `furtka-webinstaller.service` sed-patches one of its files, so `build.sh` does not take whatever archinstall the Arch repos currently ship. It downloads the version in `ARCHINSTALL_PIN` from `archive.archlinux.org`, checks it against `ARCHINSTALL_SHA256`, and serves it from a local `[furtka-pinned]` repo listed before `[core]` in the build's `pacman.conf` (pacman takes a package from the first repo that has it). Only the build uses that repo; the live system's own `pacman.conf` is untouched.
The build fails with "pinned archinstall … targets Python X, Arch now ships Y" once Arch moves to a new Python — the pinned package would install into a dead `site-packages`. To bump:
1. Pick the new version from <https://archive.archlinux.org/packages/a/archinstall/>.
2. `curl -LO` the `.pkg.tar.zst`, `sha256sum` it, and update `ARCHINSTALL_PIN` + `ARCHINSTALL_SHA256` at the top of `build.sh`.
3. Check that `webinstaller/app.py:build_disk_config` still imports and that the `ExecStartPre` sed in `furtka-webinstaller.service` still matches (`journalctl -u furtka-webinstaller` on the live ISO).
4. Full VM install (UEFI and BIOS) before merging.
`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. `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

View file

@ -27,7 +27,11 @@ fi
# ---- inside the container from here on ---- # ---- inside the container from here on ----
echo "==> Syncing pacman, installing archiso" echo "==> Syncing pacman, installing archiso"
pacman -Syu --noconfirm --needed archiso pacman -Syu --noconfirm --needed archiso curl zstd
# archinstall build the webinstaller is validated against (see pin step below).
ARCHINSTALL_PIN="4.4-1"
ARCHINSTALL_SHA256="6cb5da75c108ef2f5d25a7a20baf6c52cc927d5e06890dd10182e6eb4be815cb"
PROFILE_SRC="/usr/share/archiso/configs/releng" PROFILE_SRC="/usr/share/archiso/configs/releng"
PROFILE_WORK="/tmp/furtka-profile" PROFILE_WORK="/tmp/furtka-profile"
@ -43,6 +47,37 @@ cat "$SCRIPT_DIR/overlay/packages.extra" >> "$PROFILE_WORK/packages.x86_64"
cat "$SCRIPT_DIR/overlay/profiledef.sh" >> "$PROFILE_WORK/profiledef.sh" cat "$SCRIPT_DIR/overlay/profiledef.sh" >> "$PROFILE_WORK/profiledef.sh"
echo "==> Pinning archinstall ${ARCHINSTALL_PIN}"
# The webinstaller imports archinstall internals (build_disk_config) and
# sed-patches one of its files at service start, so a silent archinstall
# upgrade in the Arch repos can break every install with no change on our
# side. Serve the validated build from a local repo listed *before* [core]:
# pacman takes a package from the first repo that has it, whatever the
# version elsewhere. Bump deliberately: new version + sha256 below, rebuild,
# run the smoke VM (see iso/README.md).
PINNED_REPO="/tmp/furtka-pinned-repo"
rm -rf "$PINNED_REPO" && mkdir -p "$PINNED_REPO"
ARCHINSTALL_PKG="archinstall-${ARCHINSTALL_PIN}-any.pkg.tar.zst"
curl -fsSL --retry 3 -o "$PINNED_REPO/$ARCHINSTALL_PKG" \
"https://archive.archlinux.org/packages/a/archinstall/$ARCHINSTALL_PKG"
echo "${ARCHINSTALL_SHA256} $PINNED_REPO/$ARCHINSTALL_PKG" | sha256sum -c -
# archinstall is an "any" package installed under /usr/lib/python3.X; after
# Arch moves to a new Python it would install into a dead directory. Fail
# the build with a clear message instead of shipping a broken installer.
pkg_py=$(tar --zstd -tf "$PINNED_REPO/$ARCHINSTALL_PKG" \
| sed -n 's|^usr/lib/python\(3\.[0-9]*\)/.*|\1|p' | sort -u)
repo_py=$(pacman -Si python | awk -F': ' '/^Version/ {split($2, v, "."); print v[1] "." v[2]}')
if [ "$pkg_py" != "$repo_py" ]; then
echo "ERROR: pinned archinstall ${ARCHINSTALL_PIN} targets Python $pkg_py, Arch now ships $repo_py." >&2
echo " Bump ARCHINSTALL_PIN/ARCHINSTALL_SHA256 in iso/build.sh (see iso/README.md)." >&2
exit 1
fi
repo-add -q "$PINNED_REPO/furtka-pinned.db.tar.gz" "$PINNED_REPO/$ARCHINSTALL_PKG"
# The sha256 above already pins the exact bytes.
sed -i "0,/^\[core\]/s||[furtka-pinned]\nSigLevel = Optional TrustAll\nServer = file://$PINNED_REPO\n\n[core]|" \
"$PROFILE_WORK/pacman.conf"
grep -q '^\[furtka-pinned\]' "$PROFILE_WORK/pacman.conf"
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 # releng auto-logs root in on tty1. That turns our /etc/issue banner into a
@ -55,6 +90,9 @@ cp -a "$SCRIPT_DIR/overlay/airootfs/." "$PROFILE_WORK/airootfs/"
rm -rf "$PROFILE_WORK/airootfs/etc/systemd/system/getty@tty1.service.d" rm -rf "$PROFILE_WORK/airootfs/etc/systemd/system/getty@tty1.service.d"
echo "==> Rebranding boot menu (GRUB + syslinux + systemd-boot)" echo "==> Rebranding boot menu (GRUB + syslinux + systemd-boot)"
# BIOS boot menu background: Furtka lockup instead of the Arch logo
# (generated by website/tools/brand-assets.py, 640x480 as syslinux wants).
cp "$SCRIPT_DIR/overlay/splash.png" "$PROFILE_WORK/syslinux/splash.png"
# releng ships menu entries labelled "Arch Linux install medium" across three # releng ships menu entries labelled "Arch Linux install medium" across three
# bootloader configs (BIOS syslinux, GRUB, systemd-boot for UEFI). Rewrite to # bootloader configs (BIOS syslinux, GRUB, systemd-boot for UEFI). Rewrite to
# our brand. Done with sed (not a static overlay) so upstream archiso file # our brand. Done with sed (not a static overlay) so upstream archiso file

View file

@ -16,6 +16,17 @@ issue=/etc/issue
mapfile -t ips < <(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1) mapfile -t ips < <(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1)
new=$( 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

BIN
iso/overlay/splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View file

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

View file

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

View file

@ -1426,6 +1426,328 @@ def test_power_surfaces_systemd_run_missing(fake_dirs, monkeypatch):
assert "systemd-run" in body["error"] assert "systemd-run" in body["error"]
# --- Gateway endpoints -------------------------------------------------------
@pytest.fixture
def gateway_paths(fake_dirs, monkeypatch):
"""Redirect furtka.gateway's own env-cached state/lock paths into
tmp_path — same "set env, reload module" idiom fake_dirs already uses
for install_runner. gateway_config_file()/internet_routes_file() read
FURTKA_GATEWAY_CONFIG/FURTKA_INTERNET_ROUTES_FILE fresh on every call
(no caching), so they don't need a reload — only gateway.py's module-
level connect-state/lock constants do.
"""
apps_dir, _ = fake_dirs
root = apps_dir.parent
monkeypatch.setenv("FURTKA_GATEWAY_CONFIG", str(root / "gateway.json"))
monkeypatch.setenv("FURTKA_INTERNET_ROUTES_FILE", str(root / "internet-routes.json"))
monkeypatch.setenv("FURTKA_GATEWAY_CONNECT_STATE", str(root / "gateway-connect-state.json"))
monkeypatch.setenv("FURTKA_GATEWAY_CONNECT_LOCK", str(root / "gateway-connect.lock"))
import importlib
from furtka import gateway as g
importlib.reload(g)
return g
def test_gateway_status_not_connected(gateway_paths):
status, body = api._do_gateway_status()
assert status == 200
assert body == {"connected": False}
def test_gateway_status_connected(gateway_paths, monkeypatch):
gateway_paths._save_config(
{"gateway_url": "https://gw.example.com", "box_id": "abc", "box_token": "abc.x", "wg": {}}
)
from furtka import wireguard
monkeypatch.setattr(
wireguard, "status", lambda: {"configured": True, "latest_handshake": "123"}
)
status, body = api._do_gateway_status()
assert status == 200
assert body["connected"] is True
assert body["gateway_url"] == "https://gw.example.com"
assert body["box_id"] == "abc"
assert body["tunnel"] == {"configured": True, "latest_handshake": "123"}
def test_gateway_connect_requires_all_fields(gateway_paths):
status, body = api._do_gateway_connect({"gateway_url": "https://gw.example.com"})
assert status == 400
assert "required" in body["error"]
def test_gateway_connect_dispatches_systemd_run(gateway_paths, no_systemd_run):
status, body = api._do_gateway_connect(
{
"gateway_url": "https://gw.example.com",
"registration_token": "reg-tok",
"box_name": "my-box",
}
)
assert status == 202
assert body["status"] == "dispatched"
[cmd] = no_systemd_run
assert cmd[:1] == ["systemd-run"]
assert cmd[-5:] == [
"gateway",
"connect-bg",
"https://gw.example.com",
"reg-tok",
"my-box",
]
# Initial state written synchronously so the UI has something to poll
# before the background job's first write.
assert gateway_paths.read_state()["stage"] == "registering"
def test_gateway_connect_lock_contention(gateway_paths):
fh = gateway_paths.acquire_lock()
try:
status, body = api._do_gateway_connect(
{
"gateway_url": "https://gw.example.com",
"registration_token": "reg-tok",
"box_name": "my-box",
}
)
assert status == 409
assert "already in progress" in body["error"]
finally:
fh.close()
def test_gateway_connect_surfaces_systemd_run_missing(gateway_paths, monkeypatch):
def boom(*a, **kw):
raise FileNotFoundError(2, "No such file", "systemd-run")
monkeypatch.setattr("subprocess.run", boom)
status, body = api._do_gateway_connect(
{
"gateway_url": "https://gw.example.com",
"registration_token": "reg-tok",
"box_name": "my-box",
}
)
assert status == 502
assert "systemd-run" in body["error"]
assert gateway_paths.read_state()["stage"] == "error"
def test_gateway_connect_status_reads_state(gateway_paths):
gateway_paths.write_state("starting_tunnel")
status, body = api._do_gateway_connect_status()
assert status == 200
assert body["stage"] == "starting_tunnel"
def test_gateway_disconnect_when_not_connected(gateway_paths):
status, body = api._do_gateway_disconnect()
assert status == 200
assert body == {"status": "not connected"}
def test_gateway_disconnect_happy_path(gateway_paths, monkeypatch):
gateway_paths._save_config(
{"gateway_url": "https://gw.example.com", "box_id": "abc", "box_token": "abc.x", "wg": {}}
)
from furtka import wireguard
deregister_calls = []
tunnel_calls = []
monkeypatch.setattr(gateway_paths, "deregister", lambda: deregister_calls.append(1))
monkeypatch.setattr(
wireguard, "set_tunnel_enabled", lambda enabled: tunnel_calls.append(enabled)
)
status, body = api._do_gateway_disconnect()
assert status == 200
assert body == {"status": "disconnected"}
assert deregister_calls == [1]
assert tunnel_calls == [False]
def test_gateway_disconnect_surfaces_deregister_failure(gateway_paths, monkeypatch):
gateway_paths._save_config(
{"gateway_url": "https://gw.example.com", "box_id": "abc", "box_token": "abc.x", "wg": {}}
)
def boom():
raise gateway_paths.GatewayError("gateway unreachable")
monkeypatch.setattr(gateway_paths, "deregister", boom)
status, body = api._do_gateway_disconnect()
assert status == 502
assert "gateway unreachable" in body["error"]
# --- Internet exposure toggle ------------------------------------------------
VIABLE_MANIFEST = dict(
VALID_MANIFEST,
internet={"viable": True, "port": 445},
)
def test_get_internet_not_installed(gateway_paths):
status, body = api._do_get_internet("ghost")
assert status == 404
def _install_app(fake_dirs, manifest, no_docker, no_systemd_run):
apps, bundled = fake_dirs
_write_bundled(bundled, manifest["name"], manifest=manifest)
status, body = api._do_install(manifest["name"])
assert status == 202, body
return apps / manifest["name"]
def test_get_internet_not_viable_app(gateway_paths, fake_dirs, no_docker, no_systemd_run):
_install_app(fake_dirs, VALID_MANIFEST, no_docker, no_systemd_run)
status, body = api._do_get_internet("fileshare")
assert status == 404
assert "not internet-viable" in body["error"]
def test_get_internet_viable_app_not_published(gateway_paths, fake_dirs, no_docker, no_systemd_run):
_install_app(fake_dirs, VIABLE_MANIFEST, no_docker, no_systemd_run)
status, body = api._do_get_internet("fileshare")
assert status == 200
assert body == {"enabled": False}
def test_get_internet_viable_app_published(gateway_paths, fake_dirs, no_docker, no_systemd_run):
_install_app(fake_dirs, VIABLE_MANIFEST, no_docker, no_systemd_run)
api._save_internet_routes(
{"fileshare": {"route_id": "r1", "public_url": "https://fs.example.com/"}}
)
status, body = api._do_get_internet("fileshare")
assert status == 200
assert body == {"enabled": True, "public_url": "https://fs.example.com/"}
def test_set_internet_requires_enabled_bool(gateway_paths, fake_dirs, no_docker, no_systemd_run):
_install_app(fake_dirs, VIABLE_MANIFEST, no_docker, no_systemd_run)
status, body = api._do_set_internet("fileshare", {})
assert status == 400
def test_set_internet_rejects_non_viable_app(gateway_paths, fake_dirs, no_docker, no_systemd_run):
_install_app(fake_dirs, VALID_MANIFEST, no_docker, no_systemd_run)
status, body = api._do_set_internet(
"fileshare", {"enabled": True, "subdomain": "x.example.com"}
)
assert status == 400
assert "not internet-viable" in body["error"]
def test_set_internet_requires_gateway_connection(
gateway_paths, fake_dirs, no_docker, no_systemd_run
):
_install_app(fake_dirs, VIABLE_MANIFEST, no_docker, no_systemd_run)
status, body = api._do_set_internet(
"fileshare", {"enabled": True, "subdomain": "x.example.com"}
)
assert status == 409
assert "not connected" in body["error"]
def test_set_internet_enable_requires_subdomain(
gateway_paths, fake_dirs, no_docker, no_systemd_run
):
_install_app(fake_dirs, VIABLE_MANIFEST, no_docker, no_systemd_run)
gateway_paths._save_config(
{"gateway_url": "https://gw.example.com", "box_id": "abc", "box_token": "abc.x", "wg": {}}
)
status, body = api._do_set_internet("fileshare", {"enabled": True})
assert status == 400
assert "subdomain" in body["error"]
def test_set_internet_enable_happy_path(
gateway_paths, fake_dirs, no_docker, no_systemd_run, monkeypatch
):
_install_app(fake_dirs, VIABLE_MANIFEST, no_docker, no_systemd_run)
gateway_paths._save_config(
{"gateway_url": "https://gw.example.com", "box_id": "abc", "box_token": "abc.x", "wg": {}}
)
calls = []
def fake_publish(app_name, subdomain, port):
calls.append((app_name, subdomain, port))
return {"route_id": "r1", "public_url": "https://fs.example.com/"}
monkeypatch.setattr(gateway_paths, "publish_route", fake_publish)
status, body = api._do_set_internet(
"fileshare", {"enabled": True, "subdomain": "fs.example.com"}
)
assert status == 200
assert body == {"enabled": True, "public_url": "https://fs.example.com/"}
assert calls == [("fileshare", "fs.example.com", 445)]
assert api._internet_routes()["fileshare"]["route_id"] == "r1"
def test_set_internet_enable_surfaces_gateway_error(
gateway_paths, fake_dirs, no_docker, no_systemd_run, monkeypatch
):
_install_app(fake_dirs, VIABLE_MANIFEST, no_docker, no_systemd_run)
gateway_paths._save_config(
{"gateway_url": "https://gw.example.com", "box_id": "abc", "box_token": "abc.x", "wg": {}}
)
def boom(app_name, subdomain, port):
raise gateway_paths.GatewayError("subdomain already in use")
monkeypatch.setattr(gateway_paths, "publish_route", boom)
status, body = api._do_set_internet(
"fileshare", {"enabled": True, "subdomain": "fs.example.com"}
)
assert status == 502
assert "subdomain already in use" in body["error"]
assert api._internet_routes() == {}
def test_set_internet_disable_happy_path(
gateway_paths, fake_dirs, no_docker, no_systemd_run, monkeypatch
):
_install_app(fake_dirs, VIABLE_MANIFEST, no_docker, no_systemd_run)
gateway_paths._save_config(
{"gateway_url": "https://gw.example.com", "box_id": "abc", "box_token": "abc.x", "wg": {}}
)
api._save_internet_routes(
{"fileshare": {"route_id": "r1", "public_url": "https://fs.example.com/"}}
)
calls = []
monkeypatch.setattr(gateway_paths, "unpublish_route", lambda route_id: calls.append(route_id))
status, body = api._do_set_internet("fileshare", {"enabled": False})
assert status == 200
assert body == {"enabled": False}
assert calls == ["r1"]
assert api._internet_routes() == {}
def test_set_internet_disable_when_not_published_is_a_noop(
gateway_paths, fake_dirs, no_docker, no_systemd_run
):
_install_app(fake_dirs, VIABLE_MANIFEST, no_docker, no_systemd_run)
gateway_paths._save_config(
{"gateway_url": "https://gw.example.com", "box_id": "abc", "box_token": "abc.x", "wg": {}}
)
status, body = api._do_set_internet("fileshare", {"enabled": False})
assert status == 200
assert body == {"enabled": False}
# --- hardening: names, body size, cross-site POSTs, crash handling ---------- # --- hardening: names, body size, cross-site POSTs, crash handling ----------

View file

@ -87,7 +87,7 @@ def test_build_archinstall_config_includes_post_install_bootstrap(monkeypatch, t
} }
) )
for pkg in ("caddy", "avahi", "nss-mdns"): for pkg in ("caddy", "avahi", "nss-mdns", "wireguard-tools"):
assert pkg in cfg["packages"] assert pkg in cfg["packages"]
# Packaged units go in `services` (enabled before custom_commands runs); # Packaged units go in `services` (enabled before custom_commands runs);
# our own units don't exist at that point, so they must be enabled from # our own units don't exist at that point, so they must be enabled from
@ -390,3 +390,16 @@ def test_error_page_offers_retry(monkeypatch, tmp_path):
html = app_module.app.test_client().get("/install/log").get_data(as_text=True) html = app_module.app.test_client().get("/install/log").get_data(as_text=True)
assert "Try again" in html assert "Try again" in html
assert "next-steps" not in html assert "next-steps" not in html
def test_detect_bootloader_by_firmware(tmp_path):
import app as app_module
efi = tmp_path / "efi"
assert app_module._detect_bootloader(efi) == "Grub" # BIOS / legacy
efi.mkdir()
assert app_module._detect_bootloader(efi) == "Systemd-boot" # no bitness file
(efi / "fw_platform_size").write_text("64\n")
assert app_module._detect_bootloader(efi) == "Systemd-boot"
(efi / "fw_platform_size").write_text("32\n")
assert app_module._detect_bootloader(efi) == "Limine"

View file

@ -56,23 +56,27 @@ def _make_catalog_tarball(
*, *,
apps: list[tuple[str, dict]] | None = None, apps: list[tuple[str, dict]] | None = None,
extra_entries: list[tuple[str, bytes]] | None = None, extra_entries: list[tuple[str, bytes]] | None = None,
compose_overrides: dict[str, bytes] | None = None,
) -> None: ) -> None:
"""Build a minimal valid catalog tarball. """Build a minimal valid catalog tarball.
`apps` is a list of (folder_name, manifest_dict). Each app folder gets `apps` is a list of (folder_name, manifest_dict). Each app folder gets
a `manifest.json` + a stub `docker-compose.yaml` + `icon.svg`. a `manifest.json` + a stub `docker-compose.yaml` + `icon.svg`.
`compose_overrides` (folder_name -> bytes) replaces that stub content
for specific apps — for testing catalog-side compose checks (e.g.
`internet.viable` + `privileged: true`) without needing a real image.
`extra_entries` lets tests inject malformed content (path-traversal, `extra_entries` lets tests inject malformed content (path-traversal,
missing VERSION, ...) without rebuilding the helper. missing VERSION, ...) without rebuilding the helper.
""" """
apps = apps if apps is not None else [("fileshare", _manifest())] apps = apps if apps is not None else [("fileshare", _manifest())]
compose_overrides = compose_overrides or {}
buf = io.BytesIO() buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tf: with tarfile.open(fileobj=buf, mode="w:gz") as tf:
entries: list[tuple[str, bytes]] = [("VERSION", f"{version}\n".encode())] entries: list[tuple[str, bytes]] = [("VERSION", f"{version}\n".encode())]
for folder, m in apps: for folder, m in apps:
entries.append((f"apps/{folder}/manifest.json", json.dumps(m).encode())) entries.append((f"apps/{folder}/manifest.json", json.dumps(m).encode()))
entries.append( compose = compose_overrides.get(folder, b"services:\n app:\n image: scratch\n")
(f"apps/{folder}/docker-compose.yaml", b"services:\n app:\n image: scratch\n") entries.append((f"apps/{folder}/docker-compose.yaml", compose))
)
entries.append((f"apps/{folder}/icon.svg", b"<svg/>")) entries.append((f"apps/{folder}/icon.svg", b"<svg/>"))
if extra_entries: if extra_entries:
entries.extend(extra_entries) entries.extend(extra_entries)
@ -266,6 +270,76 @@ def test_sync_catalog_refuses_tarball_with_invalid_manifest(catalog, monkeypatch
assert not catalog.catalog_dir().exists() assert not catalog.catalog_dir().exists()
def test_sync_catalog_refuses_privileged_internet_viable_app(catalog, monkeypatch, tmp_path):
import hashlib
viable_manifest = dict(_manifest(), internet={"viable": True, "port": 445})
tarball_path = tmp_path / "tarball.tar.gz"
_make_catalog_tarball(
tarball_path,
"26.6",
apps=[("fileshare", viable_manifest)],
compose_overrides={
"fileshare": b"services:\n app:\n image: scratch\n privileged: true\n"
},
)
tarball_bytes = tarball_path.read_bytes()
sha = hashlib.sha256(tarball_bytes).hexdigest()
_stub_forgejo_release(monkeypatch, catalog, tag="26.6")
_stub_download(
monkeypatch,
catalog,
{
"https://forgejo.test.local/t.tar.gz": tarball_bytes,
"https://forgejo.test.local/t.tar.gz.sha256": (
f"{sha} furtka-apps-26.6.tar.gz\n".encode()
),
},
)
with pytest.raises(catalog.CatalogError, match="must not run privileged"):
catalog.sync_catalog()
assert not catalog.catalog_dir().exists()
def test_sync_catalog_allows_privileged_app_that_is_not_internet_viable(
catalog, monkeypatch, tmp_path
):
"""privileged: true is only disqualifying for internet.viable apps —
home-assistant's real catalog entry runs privileged for USB/Bluetooth
access and is simply not marked viable."""
import hashlib
tarball_path = tmp_path / "tarball.tar.gz"
_make_catalog_tarball(
tarball_path,
"26.6",
apps=[("fileshare", _manifest())],
compose_overrides={
"fileshare": b"services:\n app:\n image: scratch\n privileged: true\n"
},
)
tarball_bytes = tarball_path.read_bytes()
sha = hashlib.sha256(tarball_bytes).hexdigest()
_stub_forgejo_release(monkeypatch, catalog, tag="26.6")
_stub_download(
monkeypatch,
catalog,
{
"https://forgejo.test.local/t.tar.gz": tarball_bytes,
"https://forgejo.test.local/t.tar.gz.sha256": (
f"{sha} furtka-apps-26.6.tar.gz\n".encode()
),
},
)
catalog.sync_catalog()
assert catalog.catalog_dir().exists()
def test_sync_catalog_preserves_existing_catalog_on_failure(catalog, monkeypatch, tmp_path): def test_sync_catalog_preserves_existing_catalog_on_failure(catalog, monkeypatch, tmp_path):
"""A failed sync must leave the previous live catalog intact so boxes """A failed sync must leave the previous live catalog intact so boxes
keep working until the next successful sync.""" keep working until the next successful sync."""

View file

@ -63,6 +63,36 @@ def test_app_list_json_with_one_app(tmp_path, monkeypatch, capsys):
assert m["settings"][0]["name"] == "SMB_USER" assert m["settings"][0]["name"] == "SMB_USER"
assert m["settings"][0]["required"] is True assert m["settings"][0]["required"] is True
assert m["settings"][0]["default"] == "furtka" assert m["settings"][0]["default"] == "furtka"
assert m["internet"] == {"viable": False, "port": None}
def test_gateway_connect_bg_dispatches_to_run_connect(tmp_path, monkeypatch):
"""CLI `gateway connect-bg <url> <token> <name>` must call
gateway.run_connect(url, token, name) — the entry point the HTTP API
fires via systemd-run for POST /api/gateway/connect."""
_set_env(monkeypatch, tmp_path)
from furtka import gateway
called = []
monkeypatch.setattr(
gateway, "run_connect", lambda url, token, name: called.append((url, token, name))
)
rc = main(["gateway", "connect-bg", "https://gw.example.com", "reg-token", "my-box"])
assert rc == 0
assert called == [("https://gw.example.com", "reg-token", "my-box")]
def test_gateway_connect_bg_returns_1_on_failure(tmp_path, monkeypatch, capsys):
_set_env(monkeypatch, tmp_path)
from furtka import gateway
def boom(url, token, name):
raise RuntimeError("gateway unreachable")
monkeypatch.setattr(gateway, "run_connect", boom)
rc = main(["gateway", "connect-bg", "https://gw.example.com", "reg-token", "my-box"])
assert rc == 1
assert "gateway connect-bg failed" in capsys.readouterr().err
def test_reconcile_dry_run_empty(tmp_path, monkeypatch, capsys): def test_reconcile_dry_run_empty(tmp_path, monkeypatch, capsys):

350
tests/test_gateway.py Normal file
View file

@ -0,0 +1,350 @@
"""Tests for furtka.gateway — the box-side register/rotate/deregister client.
Same shape as test_updater.py's network tests: monkeypatch
urllib.request.urlopen directly rather than standing up a real server.
"""
from __future__ import annotations
import json
import urllib.error
import pytest
from furtka import gateway
class _FakeResp:
def __init__(self, body: dict):
self._body = json.dumps(body).encode()
def __enter__(self):
return self
def __exit__(self, *a):
return False
def read(self):
return self._body
WG = {
"private_key": "cHJpdmF0ZS1rZXk=",
"address": "10.8.0.2/24",
"server_public_key": "c2VydmVyLXB1YmxpYy1rZXk=",
"endpoint": "gateway.example.com:51820",
"allowed_ips": "10.8.0.0/24",
}
@pytest.fixture
def gw(tmp_path, monkeypatch):
monkeypatch.setenv("FURTKA_GATEWAY_CONFIG", str(tmp_path / "gateway.json"))
monkeypatch.setenv("FURTKA_GATEWAY_CONNECT_STATE", str(tmp_path / "gateway-connect-state.json"))
monkeypatch.setenv("FURTKA_GATEWAY_CONNECT_LOCK", str(tmp_path / "gateway-connect.lock"))
import importlib
from furtka import paths as p
importlib.reload(p)
importlib.reload(gateway)
return gateway
def test_load_config_returns_none_when_never_registered(gw):
assert gw.load_config() is None
def test_register_saves_config(gw, monkeypatch):
calls = []
def fake_urlopen(req, timeout):
calls.append((req.full_url, json.loads(req.data)))
return _FakeResp({"box_id": "abc123", "box_token": "abc123.secret", "wg": WG})
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
config = gw.register("https://gw.example.com/", "reg-token", "my-box")
assert config["gateway_url"] == "https://gw.example.com" # trailing slash stripped
assert config["box_id"] == "abc123"
assert config["box_token"] == "abc123.secret"
assert config["wg"] == WG
assert gw.load_config() == config
url, body = calls[0]
assert url == "https://gw.example.com/v1/boxes/register"
assert body == {"registration_token": "reg-token", "box_name": "my-box"}
def test_register_config_file_is_0600(gw, monkeypatch, tmp_path):
monkeypatch.setattr(
"urllib.request.urlopen",
lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}),
)
gw.register("https://gw.example.com", "tok", "box")
from furtka.paths import gateway_config_file
assert oct(gateway_config_file().stat().st_mode)[-3:] == "600"
def test_register_raises_gateway_error_on_http_error(gw, monkeypatch):
def raising(req, timeout):
raise urllib.error.HTTPError(req.full_url, 401, "invalid registration token", {}, None)
monkeypatch.setattr("urllib.request.urlopen", raising)
with pytest.raises(gateway.GatewayError, match="HTTP 401"):
gw.register("https://gw.example.com", "bad-token", "my-box")
assert gw.load_config() is None
def test_rotate_token_updates_stored_config(gw, monkeypatch):
monkeypatch.setattr(
"urllib.request.urlopen",
lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.old", "wg": WG}),
)
gw.register("https://gw.example.com", "tok", "box")
def fake_rotate(req, timeout):
assert req.headers.get("Authorization") == "Bearer abc.old"
assert req.full_url == "https://gw.example.com/v1/boxes/abc/rotate-token"
return _FakeResp({"box_token": "abc.new"})
monkeypatch.setattr("urllib.request.urlopen", fake_rotate)
new_token = gw.rotate_token()
assert new_token == "abc.new"
assert gw.load_config()["box_token"] == "abc.new"
def test_rotate_token_without_prior_registration_raises(gw):
with pytest.raises(gateway.GatewayError, match="not connected"):
gw.rotate_token()
def test_deregister_calls_gateway_and_clears_config(gw, monkeypatch):
monkeypatch.setattr(
"urllib.request.urlopen",
lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}),
)
gw.register("https://gw.example.com", "tok", "box")
calls = []
def fake_deregister(req, timeout):
calls.append(req.full_url)
return _FakeResp({"status": "ok"})
monkeypatch.setattr("urllib.request.urlopen", fake_deregister)
gw.deregister()
assert calls == ["https://gw.example.com/v1/boxes/abc/deregister"]
assert gw.load_config() is None
def test_deregister_when_never_registered_is_a_noop(gw, monkeypatch):
calls = []
monkeypatch.setattr("urllib.request.urlopen", lambda req, timeout: calls.append(1))
gw.deregister()
assert calls == []
def test_publish_route_requires_connection(gw):
with pytest.raises(gateway.GatewayError, match="not connected"):
gw.publish_route("vaultwarden", "vault.example.com", 8081)
def test_publish_route_posts_to_routes_endpoint(gw, monkeypatch):
monkeypatch.setattr(
"urllib.request.urlopen",
lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}),
)
gw.register("https://gw.example.com", "tok", "box")
def fake_publish(req, timeout):
assert req.full_url == "https://gw.example.com/v1/routes"
assert req.headers.get("Authorization") == "Bearer abc.x"
assert json.loads(req.data) == {
"app_name": "vaultwarden",
"subdomain": "vault.example.com",
"port": 8081,
}
return _FakeResp({"route_id": "r1", "public_url": "https://vault.example.com/"})
monkeypatch.setattr("urllib.request.urlopen", fake_publish)
result = gw.publish_route("vaultwarden", "vault.example.com", 8081)
assert result == {"route_id": "r1", "public_url": "https://vault.example.com/"}
def test_unpublish_route_requires_connection(gw):
with pytest.raises(gateway.GatewayError, match="not connected"):
gw.unpublish_route("r1")
def test_unpublish_route_calls_delete_endpoint(gw, monkeypatch):
monkeypatch.setattr(
"urllib.request.urlopen",
lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}),
)
gw.register("https://gw.example.com", "tok", "box")
calls = []
def fake_delete(req, timeout):
calls.append((req.full_url, req.get_method(), req.headers.get("Authorization")))
return _FakeResp({"status": "ok"})
monkeypatch.setattr("urllib.request.urlopen", fake_delete)
gw.unpublish_route("r1")
assert calls == [("https://gw.example.com/v1/routes/r1", "DELETE", "Bearer abc.x")]
def test_write_and_read_state_round_trip(gw):
gw.write_state("registering")
s = gw.read_state()
assert s["stage"] == "registering"
assert "updated_at" in s
def test_read_state_missing_file_returns_empty_dict(gw):
assert gw.read_state() == {}
def test_acquire_lock_contention(gw):
first = gw.acquire_lock()
try:
with pytest.raises(gateway.GatewayError, match="already in progress"):
gw.acquire_lock()
finally:
first.close()
def test_run_connect_happy_path(gw, monkeypatch):
monkeypatch.setattr(
"urllib.request.urlopen",
lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}),
)
tunnel_calls = []
monkeypatch.setattr(
gw.wireguard,
"set_tunnel_enabled",
lambda enabled, wg=None: tunnel_calls.append((enabled, wg)),
)
gw.run_connect("https://gw.example.com", "tok", "my-box")
assert tunnel_calls == [(True, WG)]
assert gw.read_state()["stage"] == "done"
assert gw.load_config()["box_id"] == "abc"
def test_run_connect_registration_failure_writes_error_state(gw, monkeypatch):
def raising(req, timeout):
raise urllib.error.HTTPError(req.full_url, 401, "invalid registration token", {}, None)
monkeypatch.setattr("urllib.request.urlopen", raising)
gw.run_connect("https://gw.example.com", "bad-tok", "my-box")
state = gw.read_state()
assert state["stage"] == "error"
assert "401" in state["error"]
assert gw.load_config() is None
def test_run_connect_tunnel_failure_writes_error_state(gw, monkeypatch):
monkeypatch.setattr(
"urllib.request.urlopen",
lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}),
)
def failing_tunnel(enabled, wg=None):
raise gw.wireguard.WireguardError("systemctl failed: bad config")
monkeypatch.setattr(gw.wireguard, "set_tunnel_enabled", failing_tunnel)
gw.run_connect("https://gw.example.com", "tok", "my-box")
state = gw.read_state()
assert state["stage"] == "error"
assert "bad config" in state["error"]
# Registration itself succeeded — config is still saved even though the
# tunnel failed to come up, so a retry doesn't need to re-register.
assert gw.load_config()["box_id"] == "abc"
def test_run_connect_retry_after_tunnel_failure_reuses_registration(gw, monkeypatch):
register_calls = []
def urlopen(req, timeout):
register_calls.append(req.full_url)
return _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG})
monkeypatch.setattr("urllib.request.urlopen", urlopen)
def failing_tunnel(enabled, wg=None):
raise gw.wireguard.WireguardError("systemctl failed: unit missing")
monkeypatch.setattr(gw.wireguard, "set_tunnel_enabled", failing_tunnel)
gw.run_connect("https://gw.example.com", "tok", "my-box")
assert gw.read_state()["stage"] == "error"
tunnel_calls = []
monkeypatch.setattr(
gw.wireguard,
"set_tunnel_enabled",
lambda enabled, wg=None: tunnel_calls.append((enabled, wg)),
)
gw.run_connect("https://gw.example.com/", "tok", "my-box")
# Registered exactly once; the retry only brought the tunnel up.
assert register_calls == ["https://gw.example.com/v1/boxes/register"]
assert tunnel_calls == [(True, WG)]
assert gw.read_state()["stage"] == "done"
assert gw.load_config()["box_id"] == "abc"
def test_run_connect_refuses_a_different_gateway_while_registered(gw, monkeypatch):
monkeypatch.setattr(
"urllib.request.urlopen",
lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}),
)
gw.register("https://gw.example.com", "tok", "box")
def must_not_call(req, timeout):
raise AssertionError("must not register with a second gateway")
monkeypatch.setattr("urllib.request.urlopen", must_not_call)
monkeypatch.setattr(gw.wireguard, "set_tunnel_enabled", lambda enabled, wg=None: None)
gw.run_connect("https://other.example.com", "tok", "box")
state = gw.read_state()
assert state["stage"] == "error"
assert "disconnect first" in state["error"]
assert gw.load_config()["gateway_url"] == "https://gw.example.com"
def test_deregister_failure_keeps_local_config_for_retry(gw, monkeypatch):
monkeypatch.setattr(
"urllib.request.urlopen",
lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}),
)
gw.register("https://gw.example.com", "tok", "box")
def raising(req, timeout):
raise urllib.error.URLError("connection refused")
monkeypatch.setattr("urllib.request.urlopen", raising)
with pytest.raises(gateway.GatewayError):
gw.deregister()
# Local config must survive so the caller can retry later.
assert gw.load_config() is not None

View file

@ -113,7 +113,7 @@ def test_set_force_enable_writes_both_snippets_and_reloads(tmp_path):
result = https.set_force_https(True, reload_caddy=fake_reload, **p) result = https.set_force_https(True, reload_caddy=fake_reload, **p)
assert result is True assert result is True
assert p["snippet"].read_text() == https.REDIRECT_CONTENT assert p["snippet"].read_text() == https._redirect_snippet_content("testbox")
written = p["https_snippet"].read_text() written = p["https_snippet"].read_text()
assert "testbox.local, testbox" in written assert "testbox.local, testbox" in written
assert "tls internal" in written assert "tls internal" in written
@ -135,7 +135,7 @@ def test_set_force_disable_removes_both_snippets(tmp_path):
p = _paths(tmp_path) p = _paths(tmp_path)
p["snippet_dir"].mkdir() p["snippet_dir"].mkdir()
p["https_snippet_dir"].mkdir() p["https_snippet_dir"].mkdir()
p["snippet"].write_text(https.REDIRECT_CONTENT) p["snippet"].write_text(https._redirect_snippet_content("testbox"))
p["https_snippet"].write_text("furtka.local { tls internal }\n") p["https_snippet"].write_text("furtka.local { tls internal }\n")
result = https.set_force_https(False, reload_caddy=lambda: None, **p) result = https.set_force_https(False, reload_caddy=lambda: None, **p)
@ -199,10 +199,16 @@ def test_systemctl_missing_raises_and_rolls_back(tmp_path):
assert not p["https_snippet"].exists() assert not p["https_snippet"].exists()
def test_redirect_snippet_content_is_caddy_redir_directive(): def test_redirect_snippet_only_covers_cert_names_and_is_temporary():
# Lock the exact directive. A regression here silently stops the # Lock the exact directives. A regression here silently stops the
# redirect from taking effect even though the file-swap looks fine. # redirect from taking effect even though the file-swap looks fine.
assert https.REDIRECT_CONTENT.strip() == "redir https://{host}{uri} permanent" # Only the names the tls-internal block has a cert for are redirected
# (an IP would hit a failing TLS handshake), and 302 so switching HTTPS
# off isn't stuck in browser caches like the old 301 was.
assert https._redirect_snippet_content("mybox") == (
"@furtka_https_host host mybox.local mybox\n"
"redir @furtka_https_host https://{host}{uri} 302\n"
)
def test_https_snippet_content_has_tls_internal_and_routes(tmp_path): def test_https_snippet_content_has_tls_internal_and_routes(tmp_path):

View file

@ -292,3 +292,57 @@ def test_requires_rejects_empty_hook_string(tmp_path):
path = _write_app(tmp_path, "z2m", payload) path = _write_app(tmp_path, "z2m", payload)
with pytest.raises(ManifestError, match="non-empty string"): with pytest.raises(ManifestError, match="non-empty string"):
load_manifest(path) load_manifest(path)
def test_internet_optional_defaults_non_viable(tmp_path):
path = _write_app(tmp_path, "fileshare", VALID_MANIFEST)
m = load_manifest(path)
assert m.internet.viable is False
assert m.internet.port is None
def test_internet_viable_parsed(tmp_path):
payload = dict(VALID_MANIFEST, internet={"viable": True, "port": 445})
path = _write_app(tmp_path, "fileshare", payload)
m = load_manifest(path)
assert m.internet.viable is True
assert m.internet.port == 445
def test_internet_non_viable_ignores_port(tmp_path):
# A non-viable app doesn't need a port at all — and if one is present
# anyway (e.g. a stale field left behind), it's simply ignored rather
# than validated, since it has no effect either way.
payload = dict(VALID_MANIFEST, internet={"viable": False, "port": 9999})
path = _write_app(tmp_path, "fileshare", payload)
m = load_manifest(path)
assert m.internet.viable is False
assert m.internet.port is None
def test_internet_viable_requires_port(tmp_path):
payload = dict(VALID_MANIFEST, internet={"viable": True})
path = _write_app(tmp_path, "fileshare", payload)
with pytest.raises(ManifestError, match="internet.port must be an integer"):
load_manifest(path)
def test_internet_port_must_be_declared(tmp_path):
payload = dict(VALID_MANIFEST, internet={"viable": True, "port": 9999})
path = _write_app(tmp_path, "fileshare", payload)
with pytest.raises(ManifestError, match="must be one of the declared ports"):
load_manifest(path)
def test_internet_non_object_rejected(tmp_path):
payload = dict(VALID_MANIFEST, internet=True)
path = _write_app(tmp_path, "fileshare", payload)
with pytest.raises(ManifestError, match="internet must be an object"):
load_manifest(path)
def test_internet_port_must_be_int_not_bool(tmp_path):
payload = dict(VALID_MANIFEST, internet={"viable": True, "port": True})
path = _write_app(tmp_path, "fileshare", payload)
with pytest.raises(ManifestError, match="internet.port must be an integer"):
load_manifest(path)

View file

@ -539,3 +539,31 @@ def test_health_check_survives_read_timeout(updater, monkeypatch):
monkeypatch.setattr("urllib.request.urlopen", slow) monkeypatch.setattr("urllib.request.urlopen", slow)
monkeypatch.setattr(updater.time, "sleep", lambda _s: None) 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 assert updater._health_check("http://127.0.0.1:7000/api/apps", deadline_s=0.05) is False
def test_refresh_caddyfile_migrates_legacy_301_redirect(updater, tmp_path):
from furtka import https
snippet_dir = tmp_path / "etc_caddy" / "furtka.d"
snippet_dir.mkdir(parents=True)
(tmp_path / "etc_caddy" / "furtka-https.d").mkdir()
(tmp_path / "etc_caddy" / "furtka-https.d" / "https.caddyfile").write_text("x\n")
redirect = snippet_dir / "redirect.caddyfile"
redirect.write_text(https.LEGACY_REDIRECT_CONTENT)
src = tmp_path / "Caddyfile.src"
src.write_text("# caddy\n")
updater._refresh_caddyfile(src)
assert redirect.read_text() == https._redirect_snippet_content("testbox")
def test_refresh_caddyfile_leaves_custom_redirect_alone(updater, tmp_path):
snippet_dir = tmp_path / "etc_caddy" / "furtka.d"
snippet_dir.mkdir(parents=True)
(tmp_path / "etc_caddy" / "furtka-https.d").mkdir()
(tmp_path / "etc_caddy" / "furtka-https.d" / "https.caddyfile").write_text("x\n")
redirect = snippet_dir / "redirect.caddyfile"
redirect.write_text("# hand-edited\n")
src = tmp_path / "Caddyfile.src"
src.write_text("# caddy\n")
updater._refresh_caddyfile(src)
assert redirect.read_text() == "# hand-edited\n"

204
tests/test_wireguard.py Normal file
View file

@ -0,0 +1,204 @@
"""Tests for furtka.wireguard — tunnel-config write/remove + systemctl toggle.
Same shape as test_https.py: stub the systemctl call so we assert the conf
file is written/removed and that a reload failure rolls the conf file back
to its pre-call state.
"""
import subprocess
from pathlib import Path
import pytest
from furtka import wireguard
WG = {
"private_key": "cHJpdmF0ZS1rZXk=",
"address": "10.8.0.2/24",
"server_public_key": "c2VydmVyLXB1YmxpYy1rZXk=",
"endpoint": "gateway.example.com:51820",
"allowed_ips": "10.8.0.0/24",
}
def test_conf_content_shape():
content = wireguard._conf_content(WG)
assert "[Interface]" in content
assert "PrivateKey = cHJpdmF0ZS1rZXk=" in content
assert "Address = 10.8.0.2/24" in content
assert "[Peer]" in content
assert "PublicKey = c2VydmVyLXB1YmxpYy1rZXk=" in content
assert "Endpoint = gateway.example.com:51820" in content
assert "AllowedIPs = 10.8.0.0/24" in content
assert "PersistentKeepalive = 25" in content
def test_conf_content_omits_preshared_key_when_absent():
# WG has no "preshared_key" key at all — the v14-gateway shape.
content = wireguard._conf_content(WG)
assert "PresharedKey" not in content
def test_conf_content_includes_preshared_key_when_present():
# v15-gateway shape (see furtka-gateway's wgeasy_v15.py) — the server
# enforces this even if the client's own conf omits it, so dropping it
# here would break the handshake silently.
wg = {**WG, "preshared_key": "cHJlc2hhcmVkLWtleQ=="}
content = wireguard._conf_content(wg)
assert "PresharedKey = cHJlc2hhcmVkLWtleQ==" in content
# Must sit between PublicKey and Endpoint, inside [Peer].
peer_section = content.split("[Peer]\n", 1)[1]
assert (
peer_section.index("PublicKey")
< peer_section.index("PresharedKey")
< peer_section.index("Endpoint")
)
def test_enable_requires_wg_config(tmp_path):
with pytest.raises(ValueError, match="wg config is required"):
wireguard.set_tunnel_enabled(True, conf_path=tmp_path / "furtka-gw.conf")
def test_enable_surfaces_clean_error_on_unreadable_conf_dir(tmp_path, monkeypatch):
# Simulated rather than via a 0o000 directory: CI runs as root, which
# ignores directory permissions.
def denied(self, *a, **kw):
raise PermissionError(13, "Permission denied", str(self))
monkeypatch.setattr(Path, "mkdir", denied)
def must_not_reload(action):
raise AssertionError("systemctl must not run when the conf can't be written")
conf_path = tmp_path / "restricted" / "furtka-gw.conf"
with pytest.raises(wireguard.WireguardError, match="could not access"):
wireguard.set_tunnel_enabled(True, wg=WG, conf_path=conf_path, reload=must_not_reload)
def test_default_conf_path_uses_env_override(monkeypatch, tmp_path):
monkeypatch.setenv("FURTKA_WIREGUARD_CONF", str(tmp_path / "custom.conf"))
assert wireguard.default_conf_path() == tmp_path / "custom.conf"
def test_default_conf_path_default_when_unset(monkeypatch):
monkeypatch.delenv("FURTKA_WIREGUARD_CONF", raising=False)
assert wireguard.default_conf_path() == wireguard.DEFAULT_CONF_PATH
def test_enable_writes_conf_and_calls_reload(tmp_path):
conf_path = tmp_path / "furtka-gw.conf"
calls = []
def fake_reload(action):
calls.append(action)
result = wireguard.set_tunnel_enabled(True, wg=WG, conf_path=conf_path, reload=fake_reload)
assert result is True
assert calls == ["enable"]
content = conf_path.read_text()
assert "PrivateKey = cHJpdmF0ZS1rZXk=" in content
assert oct(conf_path.stat().st_mode)[-3:] == "600"
def test_disable_removes_conf_and_calls_reload(tmp_path):
conf_path = tmp_path / "furtka-gw.conf"
conf_path.write_text(wireguard._conf_content(WG))
calls = []
result = wireguard.set_tunnel_enabled(
False, conf_path=conf_path, reload=lambda action: calls.append(action)
)
assert result is False
assert calls == ["disable"]
assert not conf_path.exists()
def test_disable_is_idempotent_when_already_off(tmp_path):
conf_path = tmp_path / "furtka-gw.conf"
result = wireguard.set_tunnel_enabled(False, conf_path=conf_path, reload=lambda action: None)
assert result is False
assert not conf_path.exists()
def test_reload_failure_rolls_back_enable(tmp_path):
conf_path = tmp_path / "furtka-gw.conf"
def failing_reload(action):
raise subprocess.CalledProcessError(1, ["systemctl"], stderr="bad config")
with pytest.raises(wireguard.WireguardError, match="systemctl failed: bad config"):
wireguard.set_tunnel_enabled(True, wg=WG, conf_path=conf_path, reload=failing_reload)
# No conf existed before this call, so none should exist after rollback.
assert not conf_path.exists()
def test_reload_failure_rolls_back_disable(tmp_path):
conf_path = tmp_path / "furtka-gw.conf"
original = wireguard._conf_content(WG)
conf_path.write_text(original)
def failing_reload(action):
raise subprocess.CalledProcessError(1, ["systemctl"], stderr="bad config")
with pytest.raises(wireguard.WireguardError):
wireguard.set_tunnel_enabled(False, conf_path=conf_path, reload=failing_reload)
assert conf_path.read_text() == original
def test_systemctl_missing_raises_and_rolls_back(tmp_path):
conf_path = tmp_path / "furtka-gw.conf"
def missing_systemctl(action):
raise FileNotFoundError(2, "No such file", "systemctl")
with pytest.raises(wireguard.WireguardError, match="systemctl not available"):
wireguard.set_tunnel_enabled(True, wg=WG, conf_path=conf_path, reload=missing_systemctl)
assert not conf_path.exists()
def test_status_not_configured(tmp_path):
s = wireguard.status(conf_path=tmp_path / "does-not-exist.conf")
assert s == {"configured": False, "latest_handshake": None}
def test_status_survives_unreadable_conf_path(tmp_path, monkeypatch):
"""Regression: an inaccessible parent directory raises PermissionError
even for a path that doesn't exist yet — confirmed by actually hitting
this against a real box's /etc/wireguard/ as a non-root user, which
crashed the whole /api/gateway/status request with a raw traceback.
A read-only status check must degrade to "not configured", never 500.
"""
# Simulated rather than via a 0o000 directory: CI runs as root, which
# ignores directory permissions.
def denied(self):
raise PermissionError(13, "Permission denied", str(self))
monkeypatch.setattr(Path, "is_file", denied)
s = wireguard.status(conf_path=tmp_path / "restricted" / "furtka-gw.conf")
assert s == {"configured": False, "latest_handshake": None}
def test_status_configured_reports_handshake(tmp_path):
conf_path = tmp_path / "furtka-gw.conf"
conf_path.write_text(wireguard._conf_content(WG))
s = wireguard.status(conf_path=conf_path, wg_show=lambda: "1234567890")
assert s == {"configured": True, "latest_handshake": "1234567890"}
def test_status_configured_but_wg_tools_missing(tmp_path):
conf_path = tmp_path / "furtka-gw.conf"
conf_path.write_text(wireguard._conf_content(WG))
def missing_wg():
raise FileNotFoundError(2, "No such file", "wg")
s = wireguard.status(conf_path=conf_path, wg_show=missing_wg)
assert s == {"configured": True, "latest_handshake": None}

View file

@ -515,10 +515,20 @@ def _post_install_commands(hostname, admin_username, admin_password):
] ]
def _detect_bootloader(): def _detect_bootloader(efi_dir=Path("/sys/firmware/efi")):
# systemd-boot is UEFI-only; on BIOS/legacy it trips HardwareIncompatibilityError # systemd-boot is UEFI-only; on BIOS/legacy it trips HardwareIncompatibilityError
# inside archinstall. /sys/firmware/efi exists iff we were booted via UEFI. # inside archinstall. /sys/firmware/efi exists iff we were booted via UEFI.
return "Systemd-boot" if Path("/sys/firmware/efi").exists() else "Grub" if not efi_dir.exists():
return "Grub"
# 64-bit CPU behind 32-bit UEFI (Atom Bay Trail / Cherry Trail mini-PCs
# and tablets): Arch ships no ia32 systemd-boot, and archinstall's GRUB
# path hardcodes --target=x86_64-efi. Its Limine path installs both
# BOOTIA32.EFI and BOOTX64.EFI and picks the entry by firmware bitness.
try:
bitness = (efi_dir / "fw_platform_size").read_text().strip()
except OSError:
bitness = "64"
return "Limine" if bitness == "32" else "Systemd-boot"
def build_archinstall_config(s): def build_archinstall_config(s):
@ -549,6 +559,9 @@ def build_archinstall_config(s):
"caddy", "caddy",
"avahi", "avahi",
"nss-mdns", "nss-mdns",
# furtka-gateway tunnel — furtka/wireguard.py drives
# wg-quick@furtka-gw.service, which ships in this package.
"wireguard-tools",
# Resource manager runtime — pure-stdlib Python, no pip needed # Resource manager runtime — pure-stdlib Python, no pip needed
# because we expose the package via PYTHONPATH in /usr/local/bin/furtka. # because we expose the package via PYTHONPATH in /usr/local/bin/furtka.
"python", "python",

View file

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

After

Width:  |  Height:  |  Size: 316 B

View file

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

View file

@ -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 {
@ -485,6 +693,16 @@ main.container {
.poll-bar { height: 0.45rem; border-radius: 999px; background: var(--bg-subtle); overflow: hidden; display: none; } .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-bar i { display: block; height: 100%; width: 0; background: var(--accent); transition: width 500ms ease; }
.poll--voted .poll-bar { display: block; } .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-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-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 { margin-top: 1rem; max-width: var(--measure); font-size: 0.85rem; color: var(--fg-muted); }

View file

@ -1,9 +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.20-alpha</span> — in Arbeit" status_text: "in Arbeit"
cta_download: "ISO herunterladen"
cta_source: "Quellcode"
poll_link: "/de/logo/" poll_link: "/de/logo/"
poll_text: "Wir suchen ein Logo — hilf uns beim Aussuchen" 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.
@ -12,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"

View file

@ -1,9 +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.20-alpha</span> — work in progress" status_text: "work in progress"
cta_download: "Download ISO"
cta_source: "Source code"
poll_link: "/logo/" poll_link: "/logo/"
poll_text: "We're picking a logo — help us choose" 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.
@ -12,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"

View file

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

View file

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

View file

@ -1,6 +1,11 @@
--- ---
title: "Welches Logo?" title: "Unser neues Logo"
description: "Zwei Vorschläge für das Furtka-Zeichen. Wähl das, das dir besser gefällt — ein Klick genügt." 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" translationKey: "logo"
layout: single layout: single
type: poll type: poll
@ -17,7 +22,7 @@ r:
title: "R — Fenster, Rad und F" title: "R — Fenster, Rad und F"
text: "Ein Bogenfenster mit einem Speichenrad dahinter; in der oberen rechten Scheibe wird die Speiche zum F." text: "Ein Bogenfenster mit einem Speichenrad dahinter; in der oberen rechten Scheibe wird die Speiche zum F."
show: show:
intro: "Noch unentschieden? Hier ist jedes Zeichen überall dort, wo es funktionieren muss." intro: "Beide Zeichen überall dort, wo sie hätten funktionieren müssen."
sizes: "Groß und klein" 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." 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: "Mit dem Namen"
@ -48,6 +53,6 @@ labels:
*Furtka* ist polnisch für die kleine Gartenpforte — die Tür im Zaun, durch *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 die man hineinkommt, ohne das große Tor aufzumachen. Beide Zeichen spielen
damit: das eine zeichnet den Buchstaben ins Tor, das andere schaut durchs damit: das eine zeichnet den Buchstaben ins Tor, das andere schaut durchs
Tor auf ein Rad. Stimm direkt hier ab — oder scroll erst weiter und sieh Tor auf ein Rad. Unten stehen beide mit dem Endergebnis — und jedes Zeichen
beide Zeichen überall, wo sie funktionieren müssen, vom Poster bis zum überall dort, wo es hätte funktionieren müssen, vom Poster bis zum winzigen
winzigen Symbol im Browser-Tab. Symbol im Browser-Tab.

View file

@ -1,6 +1,11 @@
--- ---
title: "Which logo?" title: "Our new logo"
description: "Two proposals for the Furtka mark. Pick the one you like better — it takes one click." 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" translationKey: "logo"
url: /logo/ url: /logo/
layout: single layout: single
@ -18,7 +23,7 @@ r:
title: "R — window, wheel and F" 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." text: "An arched window with a wheel of spokes behind it; in the top-right pane the spoke becomes an F."
show: show:
intro: "Not sure yet? Here is each mark everywhere it would have to work." intro: "Both marks everywhere they would have had to work."
sizes: "Big and small" 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." 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: "With the name"
@ -49,5 +54,5 @@ labels:
*Furtka* is Polish for a small garden gate — the little door in the fence *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: 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 one draws the letter into the gate, the other looks through the gate at a
wheel. Vote right here — or scroll on first and see both marks everywhere wheel. Below are both, with the final count — and each mark everywhere it
they would have to work, from a poster to the tiny icon in a browser tab. would have had to work, from a poster to the tiny icon in a browser tab.

View file

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

View file

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

View file

@ -1,19 +1,20 @@
{{ 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 }} {{ with .Params.poll_link }}
<p class="reveal"><a class="home-poll" href="{{ . }}">{{ partial "mark-m.html" (dict "class" "" "id" "hm") }}{{ partial "mark-r.html" (dict "class" "" "id" "hr") }}<span>{{ $.Params.poll_text }} →</span></a></p> <p class="reveal"><a class="home-poll" href="{{ . }}">{{ partial "mark-m.html" (dict "class" "" "id" "hm") }}<span>{{ $.Params.poll_text }} →</span></a></p>
{{ end }} {{ end }}
</header> </header>
@ -21,6 +22,20 @@
<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 }}
@ -43,6 +58,16 @@
</section> </section>
{{ end }} {{ end }}
{{ with .Params.band_title }}
<section class="dl-band" data-gsap="card">
<div>
<h2>{{ . }}</h2>
<p>{{ $.Params.band_text }}</p>
</div>
<a class="cta cta--primary" href="{{ $dl }}">{{ $.Params.cta_download }} <span aria-hidden="true">→</span></a>
</section>
{{ end }}
{{ with .Content }} {{ with .Content }}
<section class="prose closer">{{ . }}</section> <section class="prose closer">{{ . }}</section>
{{ end }} {{ end }}

View file

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

Before

Width:  |  Height:  |  Size: 384 B

After

Width:  |  Height:  |  Size: 390 B

View file

@ -10,12 +10,13 @@
<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 }}<meta property="og:image" content="{{ . | absURL }}"> {{ 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:width" content="1200">
<meta property="og:image:height" content="630"> <meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">{{ end }} <meta name="twitter:card" content="summary_large_image">{{ end }}

View file

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

View file

@ -4,32 +4,43 @@
side gets the "first thing on the page" advantage for the whole poll. */}} side gets the "first thing on the page" advantage for the whole poll. */}}
{{- $order := $p.order | default (slice "m" "r") -}} {{- $order := $p.order | default (slice "m" "r") -}}
{{- $s := $p.show -}} {{- $s := $p.show -}}
<article class="poll" data-poll="{{ $p.poll }}" data-api="/api/poll/{{ $p.poll }}"> <article class="{{ if $p.closed }}poll-closed{{ else }}poll{{ end }}" data-poll="{{ $p.poll }}" data-api="/api/poll/{{ $p.poll }}">
<header class="page-header"> <header class="page-header">
<h1>{{ .Title }}</h1> <h1>{{ .Title }}</h1>
{{ with $p.description }}<p class="lede">{{ . }}</p>{{ end }} {{ with $p.description }}<p class="lede">{{ . }}</p>{{ end }}
</header> </header>
{{ with $p.result }}<p class="poll-result" role="status">{{ . | markdownify }}</p>{{ end }}
<div class="prose">{{ .Content }}</div> <div class="prose">{{ .Content }}</div>
{{/* ── Quick vote ─────────────────────────────────────────── */}} {{/* ── Quick vote ─────────────────────────────────────────── */}}
<div class="poll-grid" id="vote"> <div class="poll-grid" id="vote">
{{ range $k := $order }}{{ $d := index site.Data.logo $k }} {{ range $k := $order }}{{ $d := index site.Data.logo $k }}
{{ $c := index $p $k }} {{ $c := index $p $k }}
<section class="poll-card" data-choice="{{ $k }}" aria-labelledby="poll-{{ $k }}-title"> {{- $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> <div class="poll-art poll-art--{{ $k }}">{{ $d.display | safeHTML }}</div>
<h2 id="poll-{{ $k }}-title">{{ $c.title }}</h2> <h2 id="poll-{{ $k }}-title">{{ $c.title }}</h2>
<p class="poll-desc">{{ $c.text }}</p> <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> <button type="button" class="poll-btn" data-vote="{{ $k }}">{{ $p.labels.vote }}</button>
<div class="poll-bar" aria-hidden="true"><i></i></div> <div class="poll-bar" aria-hidden="true"><i></i></div>
<p class="poll-count" data-count="{{ $k }}"></p> <p class="poll-count" data-count="{{ $k }}"></p>
{{ end }}
</section> </section>
{{ end }} {{ end }}
</div> </div>
{{ if not $p.closed }}
<p class="poll-status" role="status" aria-live="polite" <p class="poll-status" role="status" aria-live="polite"
data-t-voted="{{ $p.labels.voted }}" data-t-voted="{{ $p.labels.voted }}"
data-t-total="{{ $p.labels.total }}" data-t-total="{{ $p.labels.total }}"
data-t-error="{{ $p.labels.error }}" data-t-error="{{ $p.labels.error }}"
data-t-change="{{ $p.labels.change }}">{{ $p.labels.nojs }}</p> data-t-change="{{ $p.labels.change }}">{{ $p.labels.nojs }}</p>
{{ end }}
{{/* ── Showcase ───────────────────────────────────────────── */}} {{/* ── Showcase ───────────────────────────────────────────── */}}
<div class="show"> <div class="show">
@ -148,6 +159,7 @@
</div> </div>
{{/* ── Vote again at the end ─────────────────────────────── */}} {{/* ── Vote again at the end ─────────────────────────────── */}}
{{ if not $p.closed }}
<section class="poll-final"> <section class="poll-final">
<h2 class="show-h">{{ $s.final }}</h2> <h2 class="show-h">{{ $s.final }}</h2>
<div class="poll-final-row"> <div class="poll-final-row">
@ -162,7 +174,10 @@
</div> </div>
</section> </section>
<p class="poll-note">{{ $p.labels.note | safeHTML }}</p> <p class="poll-note">{{ $p.labels.note | safeHTML }}</p>
{{ end }}
</article> </article>
{{ if not $p.closed }}
{{ $js := resources.Get "js/poll.js" | minify | fingerprint }} {{ $js := resources.Get "js/poll.js" | minify | fingerprint }}
<script defer src="{{ $js.RelPermalink }}" integrity="{{ $js.Data.Integrity }}"></script> <script defer src="{{ $js.RelPermalink }}" integrity="{{ $js.Data.Integrity }}"></script>
{{ end }} {{ end }}
{{ end }}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

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

Before

Width:  |  Height:  |  Size: 334 B

After

Width:  |  Height:  |  Size: 316 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""Generate the Furtka brand files from the winning logo (Mark M) everywhere
it ships: website, live installer, on-box UI, ISO boot splash, repo docs.
python3 website/tools/brand-assets.py
Reuses the geometry and rasteriser of logo-assets.py (the poll showcase), so
the mark is drawn from one source. Needs the same build-only deps: cairosvg,
Pillow, fontTools. Output files are committed; re-run after a geometry change.
"""
import importlib.util
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.abspath(os.path.join(HERE, "..", ".."))
_spec = importlib.util.spec_from_file_location("logo_assets", os.path.join(HERE, "logo-assets.py"))
la = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(la)
K = "m"
COLOR = la.MARKS[K]["color"] # light-theme accent
COLOR_DARK = "#ff6b56" # dark-theme accent used by the site and the box UI
INK = la.MARKS[K]["ink"]
def out(*parts):
path = os.path.join(REPO, *parts)
os.makedirs(os.path.dirname(path), exist_ok=True)
return path
def favicon_svg():
"""Small-size tier, accent colour, lighter accent on dark browser chrome."""
inner = la.body(K, "min", "currentColor")
x, y, w, h = la.MARKS[K]["vb"]
return (
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{x} {y} {w} {h}">'
f"<style>svg{{color:{COLOR}}}"
f"@media (prefers-color-scheme:dark){{svg{{color:{COLOR_DARK}}}}}</style>"
f"{inner}</svg>\n"
)
def mark_svg(tier, color):
return (
la.svg(K, tier, color, standalone=True)
.replace(' class="pm"', "")
.replace(' aria-hidden="true" focusable="false"', "")
+ "\n"
)
def lockup_svg(color):
s = la.lockup(K).replace("currentColor", color)
return (
s.replace('<svg class="pm"', f"<svg {la.XMLNS}").replace(
' aria-hidden="true" focusable="false"', ' role="img" aria-label="Furtka"'
)
+ "\n"
)
def write(path, text):
with open(path, "w") as f:
f.write(text)
def main():
fav = favicon_svg()
for p in (
("website", "static", "favicon.svg"),
("webinstaller", "static", "favicon.svg"),
("assets", "www", "favicon.svg"),
):
write(out(*p), fav)
la.tile(K, 180, pad=0.2).convert("RGB").save(
out("website", "static", "apple-touch-icon.png"), optimize=True
)
# Default share card for pages without their own og:image.
la.lockup_png(K, 1200, 630, False, 230).convert("RGB").save(
out("website", "static", "img", "brand", "social.png"), optimize=True
)
# syslinux (BIOS boot menu) wants a 640x480 PNG and draws its menu over
# the middle of it, so the lockup sits small in the top band — same
# layout as releng's Arch splash, which build.sh replaces with this.
splash_band = la.lockup_png(K, 640, 120, False, 44)
splash = la.Image.new("RGBA", (640, 480), INK)
splash.alpha_composite(splash_band, (0, 8))
splash.convert("RGB").save(out("iso", "overlay", "splash.png"), optimize=True)
write(out("docs", "brand", "furtka-mark.svg"), mark_svg("flat", COLOR))
write(out("docs", "brand", "furtka-mark-small.svg"), mark_svg("min", COLOR))
write(out("docs", "brand", "furtka-lockup.svg"), lockup_svg(COLOR))
write(out("docs", "brand", "furtka-lockup-white.svg"), lockup_svg("#ffffff"))
write(out("docs", "brand", "console-banner.txt"), la.banner(K, cols=20) + "\n")
print("brand assets ok")
if __name__ == "__main__":
sys.exit(main())