Compare commits
22 commits
fix/follow
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0996262fee | |||
| c00a01e940 | |||
| cdc0c3c6fd | |||
| 7053ef290c | |||
| 24beffeaf4 | |||
| 5830f8887a | |||
| 9076b85304 | |||
| 2f6c31a26e | |||
| 2f7a02f1d1 | |||
| d9a10f76ea | |||
| e251eb831a | |||
| 0490f49f85 | |||
| f95bfcb56b | |||
| 91aa33b891 | |||
| 0cad2e05c8 | |||
| 905845187d | |||
| 30ee044657 | |||
| f88412d9e8 | |||
| 91f2d6304c | |||
| e88b57f74d | |||
| 557d410eca | |||
| 43bcf3c4e5 |
|
|
@ -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
|
||||||
|
|
|
||||||
29
CHANGELOG.md
|
|
@ -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
|
||||||
|
|
@ -78,6 +101,9 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
|
||||||
- ISO builds pin archinstall (4.4-1, sha256-checked from the Arch archive)
|
- 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
|
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`.
|
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
|
||||||
|
|
||||||
|
|
@ -633,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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,8 @@
|
||||||
"git",
|
"git",
|
||||||
"htop",
|
"htop",
|
||||||
"curl",
|
"curl",
|
||||||
"openssh"
|
"openssh",
|
||||||
|
"wireguard-tools"
|
||||||
],
|
],
|
||||||
|
|
||||||
"profile": {
|
"profile": {
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><style>svg{color:#c03a28}@media (prefers-color-scheme:dark){svg{color:#ff6b56}}</style><path fill="currentColor" fill-rule="evenodd" d="M5 60V27a27 27 0 0 1 54 0v33Z M19.5 60V26A10 10 0 0 1 29.5 16H46v10H32a2.5 2.5 0 0 0-2.5 2.5V32H46v10H29.5v18Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 316 B |
|
|
@ -2,6 +2,7 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<title>Install local HTTPS · Furtka</title>
|
<title>Install local HTTPS · Furtka</title>
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<link rel="stylesheet" href="/style.css">
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<title>Furtka</title>
|
<title>Furtka</title>
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<link rel="stylesheet" href="/style.css">
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
|
|
||||||
|
|
@ -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' });
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -0,0 +1,19 @@
|
||||||
|
# Furtka brand files
|
||||||
|
|
||||||
|
The Furtka logo is **Mark M** — a lowercase *f* cut out of an arched gate. It won the
|
||||||
|
public logo vote on furtka.org (closed 2026-09-28: M 29, R 13 of 42 votes).
|
||||||
|
|
||||||
|
| File | Use |
|
||||||
|
|---|---|
|
||||||
|
| `furtka-mark.svg` | The mark, 32 px and up |
|
||||||
|
| `furtka-mark-small.svg` | Simplified drawing for 16–24 px (favicons, tab icons) |
|
||||||
|
| `furtka-lockup.svg` | Mark + wordmark, accent colour on light grounds |
|
||||||
|
| `furtka-lockup-white.svg` | Mark + wordmark in white, for dark or accent grounds |
|
||||||
|
| `console-banner.txt` | Text-mode art for the console welcome screens |
|
||||||
|
|
||||||
|
Colours: accent `#c03a28` (dark UI: `#ff6b56`), ink `#0d0d0f`.
|
||||||
|
|
||||||
|
Everything here and the copies in `website/static/`, `webinstaller/static/`,
|
||||||
|
`assets/www/` and `iso/overlay/splash.png` are generated by
|
||||||
|
`website/tools/brand-assets.py` from the geometry in `website/tools/logo-assets.py`.
|
||||||
|
Change the geometry there and re-run the script — don't edit the outputs by hand.
|
||||||
10
docs/brand/console-banner.txt
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
▄▄▄▄▄▄▄▄
|
||||||
|
▄██████████▄
|
||||||
|
█████▀▀▀▀▀▀███
|
||||||
|
████▀ ████
|
||||||
|
████ █████████
|
||||||
|
████ ████
|
||||||
|
████ ▄▄▄▄▄████
|
||||||
|
████ █████████
|
||||||
|
████ █████████
|
||||||
|
▀▀▀▀ ▀▀▀▀▀▀▀▀▀
|
||||||
1
docs/brand/furtka-lockup-white.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -4 232.44 64.00" role="img" aria-label="Furtka"><g transform="scale(1.0000) translate(-6 -2)"><path fill="#ffffff" fill-rule="evenodd" d="M6 60V28a26 26 0 0 1 52 0v32Z M20.0 60V26.5A10.5 10.5 0 0 1 30.5 16H45.0v8.6H32.5a3.5 3.5 0 0 0-3.5 3.5V32h16v8.6H29.0V60Z"/></g><g fill="#ffffff"><path transform="translate(68.24 58.00) scale(0.05354 -0.05354)" d="M115 0V494H30V600H115V637Q115 687 132.5 724.0Q150 761 192.0 781.5Q234 802 305 802Q333 802 358 799V695Q344 698 324 698Q287 698 273.5 682.0Q260 666 260 632V600H358V494H260V0Z"/><path transform="translate(87.21 58.00) scale(0.05354 -0.05354)" d="M271 -15Q169 -15 119.0 48.5Q69 112 69 218V600H218V244Q218 179 241.5 143.0Q265 107 316 107Q379 107 413.0 156.0Q447 205 447 276V600H596V0H462V90H438Q423 44 382.5 14.5Q342 -15 271 -15Z"/><path transform="translate(121.45 58.00) scale(0.05354 -0.05354)" d="M76 0V600H211V492H235Q241 521 257.0 547.0Q273 573 307.0 589.5Q341 606 399 606H428V471H387Q301 471 263.0 427.0Q225 383 225 294V0Z"/><path transform="translate(144.17 58.00) scale(0.05354 -0.05354)" d="M162 0Q139 27 125.5 65.0Q112 103 112 179V494H30V600H114V750H259V600H358V494H260V196Q260 116 278.0 74.5Q296 33 321 6V0Z"/><path transform="translate(163.14 58.00) scale(0.05354 -0.05354)" d="M78 0V800H226V366H274L449 600H608V594L397 320L632 6V0H457L274 254H226V0Z"/><path transform="translate(195.88 58.00) scale(0.05354 -0.05354)" d="M280 -13Q198 -13 144.0 27.5Q90 68 63.5 138.5Q37 209 37 301Q37 395 64.0 465.0Q91 535 145.0 574.0Q199 613 280 613Q344 613 384.5 587.5Q425 562 445 522H469V600H606V0H469V78H445Q425 41 385.0 14.0Q345 -13 280 -13ZM317 95Q381 95 419.5 151.0Q458 207 458 300Q458 394 419.5 449.5Q381 505 317 505Q257 505 223.5 453.0Q190 401 190 300Q190 199 223.5 147.0Q257 95 317 95Z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
1
docs/brand/furtka-lockup.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -4 232.44 64.00" role="img" aria-label="Furtka"><g transform="scale(1.0000) translate(-6 -2)"><path fill="#c03a28" fill-rule="evenodd" d="M6 60V28a26 26 0 0 1 52 0v32Z M20.0 60V26.5A10.5 10.5 0 0 1 30.5 16H45.0v8.6H32.5a3.5 3.5 0 0 0-3.5 3.5V32h16v8.6H29.0V60Z"/></g><g fill="#c03a28"><path transform="translate(68.24 58.00) scale(0.05354 -0.05354)" d="M115 0V494H30V600H115V637Q115 687 132.5 724.0Q150 761 192.0 781.5Q234 802 305 802Q333 802 358 799V695Q344 698 324 698Q287 698 273.5 682.0Q260 666 260 632V600H358V494H260V0Z"/><path transform="translate(87.21 58.00) scale(0.05354 -0.05354)" d="M271 -15Q169 -15 119.0 48.5Q69 112 69 218V600H218V244Q218 179 241.5 143.0Q265 107 316 107Q379 107 413.0 156.0Q447 205 447 276V600H596V0H462V90H438Q423 44 382.5 14.5Q342 -15 271 -15Z"/><path transform="translate(121.45 58.00) scale(0.05354 -0.05354)" d="M76 0V600H211V492H235Q241 521 257.0 547.0Q273 573 307.0 589.5Q341 606 399 606H428V471H387Q301 471 263.0 427.0Q225 383 225 294V0Z"/><path transform="translate(144.17 58.00) scale(0.05354 -0.05354)" d="M162 0Q139 27 125.5 65.0Q112 103 112 179V494H30V600H114V750H259V600H358V494H260V196Q260 116 278.0 74.5Q296 33 321 6V0Z"/><path transform="translate(163.14 58.00) scale(0.05354 -0.05354)" d="M78 0V800H226V366H274L449 600H608V594L397 320L632 6V0H457L274 254H226V0Z"/><path transform="translate(195.88 58.00) scale(0.05354 -0.05354)" d="M280 -13Q198 -13 144.0 27.5Q90 68 63.5 138.5Q37 209 37 301Q37 395 64.0 465.0Q91 535 145.0 574.0Q199 613 280 613Q344 613 384.5 587.5Q425 562 445 522H469V600H606V0H469V78H445Q425 41 385.0 14.0Q345 -13 280 -13ZM317 95Q381 95 419.5 151.0Q458 207 458 300Q458 394 419.5 449.5Q381 505 317 505Q257 505 223.5 453.0Q190 401 190 300Q190 199 223.5 147.0Q257 95 317 95Z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
1
docs/brand/furtka-mark-small.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="#c03a28" fill-rule="evenodd" d="M5 60V27a27 27 0 0 1 54 0v33Z M19.5 60V26A10 10 0 0 1 29.5 16H46v10H32a2.5 2.5 0 0 0-2.5 2.5V32H46v10H29.5v18Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 224 B |
1
docs/brand/furtka-mark.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="#c03a28" fill-rule="evenodd" d="M6 60V28a26 26 0 0 1 52 0v32Z M20.0 60V26.5A10.5 10.5 0 0 1 30.5 16H45.0v8.6H32.5a3.5 3.5 0 0 0-3.5 3.5V32h16v8.6H29.0V60Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 236 B |
333
furtka/api.py
|
|
@ -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,15 +391,23 @@ 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}
|
||||||
|
<details class="menu">
|
||||||
|
<summary class="btn secondary" aria-label="More actions for ${esc(a.display_name || a.name)}">⋯</summary>
|
||||||
|
<div class="menu-list">
|
||||||
${hasSettings ? `<button data-op="edit" data-name="${esc(a.name)}">Settings</button>` : ''}
|
${hasSettings ? `<button data-op="edit" data-name="${esc(a.name)}">Settings</button>` : ''}
|
||||||
<button class="secondary" data-op="update" data-name="${esc(a.name)}">Update</button>
|
${internetItem}
|
||||||
<button class="secondary" data-op="reinstall" data-name="${esc(a.name)}">Reinstall</button>
|
<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>
|
<button class="danger" data-op="remove" data-name="${esc(a.name)}">Remove</button>
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('')
|
}).join('')
|
||||||
: '<div class="empty">No apps installed yet.</div>';
|
: '<div class="empty">No apps installed yet.</div>';
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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")
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
160
furtka/wireguard.py
Normal 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}
|
||||||
|
|
@ -90,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
|
||||||
|
|
|
||||||
|
|
@ -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
|
After Width: | Height: | Size: 4.3 KiB |
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 ----------
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
204
tests/test_wireguard.py
Normal 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}
|
||||||
|
|
@ -559,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",
|
||||||
|
|
|
||||||
1
webinstaller/static/favicon.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><style>svg{color:#c03a28}@media (prefers-color-scheme:dark){svg{color:#ff6b56}}</style><path fill="currentColor" fill-rule="evenodd" d="M5 60V27a27 27 0 0 1 54 0v33Z M19.5 60V26A10 10 0 0 1 29.5 16H46v10H32a2.5 2.5 0 0 0-2.5 2.5V32H46v10H29.5v18Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 316 B |
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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); }
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
40
website/content/download.de.md
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
---
|
||||||
|
title: "Furtka herunterladen"
|
||||||
|
description: "Der Furtka-Installer als ISO-Datei: auf einen USB-Stick schreiben, den Computer davon starten und den Heimserver im Browser einrichten."
|
||||||
|
translationKey: "download"
|
||||||
|
url: /de/download/
|
||||||
|
type: download
|
||||||
|
button: "ISO herunterladen"
|
||||||
|
released: "Veröffentlicht"
|
||||||
|
size_label: "Größe"
|
||||||
|
alpha: "Furtka ist **Alpha-Software**: Dinge ändern sich und manches geht kaputt. Installiere es auf einem Computer, dessen Festplatte du nicht brauchst, und sichere alles Wichtige."
|
||||||
|
host_note: "Die Datei kommt von unserem Forgejo-Server (forgejo.sourcegate.online)."
|
||||||
|
need_label: "Was du brauchst"
|
||||||
|
needs:
|
||||||
|
- "Einen **64-Bit-PC** (x86-64) — ein alter Laptop oder Desktop reicht. UEFI- und ältere BIOS-Rechner funktionieren beide."
|
||||||
|
- "Eine Festplatte mit **mindestens 16 GB** für Furtka. **Sie wird komplett gelöscht.**"
|
||||||
|
- "Einen **USB-Stick** mit mindestens 2 GB (wird ebenfalls gelöscht)."
|
||||||
|
- "Ein **Netzwerkkabel** mit Internetzugang — der Installer lädt Pakete herunter, und WLAN wird nicht automatisch eingerichtet."
|
||||||
|
- "Ein **zweites Gerät mit Browser** (Handy oder Laptop) im selben Netzwerk."
|
||||||
|
steps_label: "So installierst du Furtka"
|
||||||
|
steps:
|
||||||
|
- title: "ISO auf einen USB-Stick schreiben"
|
||||||
|
body: |
|
||||||
|
Nimm [balenaEtcher](https://etcher.balena.io/) (Windows, macOS, Linux) oder [Rufus](https://rufus.ie/) unter Windows — wenn Rufus fragt, wähle den **DD-Abbild-Modus**. Unter Linux geht auch `dd` (ersetze `/dev/sdX` durch deinen Stick — lieber zweimal prüfen):
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo dd if={iso} of=/dev/sdX bs=4M status=progress oflag=sync
|
||||||
|
```
|
||||||
|
- title: "Computer vom Stick starten"
|
||||||
|
body: "Stick und Netzwerkkabel einstecken, Computer einschalten und die Taste fürs Bootmenü drücken (oft <kbd>F11</kbd>, <kbd>F12</kbd> oder <kbd>Esc</kbd> — sie wird kurz am Bildschirm angezeigt). Den USB-Stick auswählen. Startet der Computer nicht davon, schalte **Secure Boot** in den Firmware-Einstellungen aus."
|
||||||
|
- title: "Installer von einem anderen Gerät öffnen"
|
||||||
|
body: "Nach kurzer Zeit zeigt der Bildschirm eine Adresse an — meist **http://proksi.local:5000**, dazu die IP-Adresse als Ausweichweg. Öffne sie im Browser auf Handy oder Laptop."
|
||||||
|
- title: "Durch den Assistenten klicken"
|
||||||
|
body: "Drei Seiten: ein Name für deinen Server mit Benutzername und Passwort, die Festplatte und eine letzte Kontrolle. Danach lädt der Installer alles herunter und richtet es ein."
|
||||||
|
- title: "Stick raus, neu starten, fertig"
|
||||||
|
body: "Wenn der Assistent es sagt, den USB-Stick abziehen und neu starten. Dein Server ist dann unter **http://<name>.local** erreichbar — zum Beispiel http://furtka.local."
|
||||||
|
verify_label: "Download prüfen (optional)"
|
||||||
|
verify_intro: "Vergleiche die Prüfsumme der heruntergeladenen Datei mit dieser:"
|
||||||
|
verify_missing: "Für die ISO dieses Releases ist noch keine Prüfsumme veröffentlicht — künftige Releases bringen eine mit."
|
||||||
|
older: "Alle Releases und Änderungen"
|
||||||
|
---
|
||||||
40
website/content/download.md
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
---
|
||||||
|
title: "Download Furtka"
|
||||||
|
description: "The Furtka installer as an ISO file: write it to a USB stick, start your computer from it, and set up your home server in the browser."
|
||||||
|
translationKey: "download"
|
||||||
|
url: /download/
|
||||||
|
type: download
|
||||||
|
button: "Download ISO"
|
||||||
|
released: "Released"
|
||||||
|
size_label: "Size"
|
||||||
|
alpha: "Furtka is **alpha software**: things change and some will break. Install it on a computer whose disk you don't need, and keep a backup of anything important."
|
||||||
|
host_note: "The file is served from our Forgejo server (forgejo.sourcegate.online)."
|
||||||
|
need_label: "What you need"
|
||||||
|
needs:
|
||||||
|
- "A **64-bit PC** (x86-64) — an old laptop or desktop is fine. Both UEFI and older BIOS computers work."
|
||||||
|
- "A disk of **at least 16 GB** for Furtka. **It will be erased completely.**"
|
||||||
|
- "A **USB stick** with at least 2 GB (also erased)."
|
||||||
|
- "A **network cable** with internet access — the installer downloads packages, and Wi-Fi isn't set up automatically."
|
||||||
|
- "A **second device with a browser** (phone or laptop) on the same network."
|
||||||
|
steps_label: "How to install"
|
||||||
|
steps:
|
||||||
|
- title: "Write the ISO to a USB stick"
|
||||||
|
body: |
|
||||||
|
Use [balenaEtcher](https://etcher.balena.io/) (Windows, macOS, Linux) or [Rufus](https://rufus.ie/) on Windows — if Rufus asks, pick **DD image mode**. On Linux you can also use `dd` (replace `/dev/sdX` with your stick — double-check it):
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo dd if={iso} of=/dev/sdX bs=4M status=progress oflag=sync
|
||||||
|
```
|
||||||
|
- title: "Start the computer from the stick"
|
||||||
|
body: "Plug in the stick and the network cable, switch the computer on and press the one-time boot menu key (often <kbd>F11</kbd>, <kbd>F12</kbd> or <kbd>Esc</kbd> — it flashes briefly on screen). Pick the USB stick. If the computer refuses to start from it, switch off **Secure Boot** in the firmware settings."
|
||||||
|
- title: "Open the installer from another device"
|
||||||
|
body: "After a short while the screen shows an address — usually **http://proksi.local:5000**, plus the IP address as a fallback. Open it in a browser on your phone or laptop."
|
||||||
|
- title: "Click through the wizard"
|
||||||
|
body: "Three pages: a name for your server with your username and password, the disk to install on, and a last check. Then the installer downloads and sets everything up."
|
||||||
|
- title: "Pull the stick, restart, done"
|
||||||
|
body: "When the wizard says so, remove the USB stick and restart. Your server comes up at **http://<name>.local** — for example http://furtka.local."
|
||||||
|
verify_label: "Check the download (optional)"
|
||||||
|
verify_intro: "Compare the checksum of the file you downloaded with this one:"
|
||||||
|
verify_missing: "This release has no published checksum for the ISO yet — future releases include one."
|
||||||
|
older: "All releases and changelogs"
|
||||||
|
---
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
63
website/layouts/download/single.html
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
{{ define "main" }}
|
||||||
|
{{- $r := partialCached "release.html" . "release" -}}
|
||||||
|
{{- $file := path.Base $r.url -}}
|
||||||
|
<article class="download">
|
||||||
|
<header class="dl-hero">
|
||||||
|
<p class="status-chip"><span class="mono">{{ $r.version }}</span></p>
|
||||||
|
<h1>{{ .Title }}</h1>
|
||||||
|
<p class="lede">{{ .Description }}</p>
|
||||||
|
|
||||||
|
<div class="dl-card">
|
||||||
|
<a class="cta cta--primary" href="{{ $r.url }}" download>
|
||||||
|
<svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M5 21h14"/></svg>
|
||||||
|
{{ .Params.button }}
|
||||||
|
</a>
|
||||||
|
<dl class="dl-meta">
|
||||||
|
<div><dt>ISO</dt><dd class="mono">{{ $file }}</dd></div>
|
||||||
|
{{ with $r.size }}<div><dt>{{ $.Params.size_label }}</dt><dd>{{ lang.FormatNumber 1 (div (float .) 1e9) }} GB</dd></div>{{ end }}
|
||||||
|
{{ with $r.date }}<div><dt>{{ $.Params.released }}</dt><dd>{{ time.Format ":date_long" . }}</dd></div>{{ end }}
|
||||||
|
</dl>
|
||||||
|
<p class="dl-host">{{ .Params.host_note }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="dl-alpha">{{ .Params.alpha | markdownify }}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="dl-section">
|
||||||
|
<p class="section-eyebrow">{{ .Params.need_label }}</p>
|
||||||
|
<ul class="dl-needs">
|
||||||
|
{{ range .Params.needs }}<li>{{ . | markdownify }}</li>{{ end }}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="dl-section">
|
||||||
|
<p class="section-eyebrow">{{ .Params.steps_label }}</p>
|
||||||
|
<ol class="dl-steps">
|
||||||
|
{{ range .Params.steps }}
|
||||||
|
<li class="step-card">
|
||||||
|
<h2>{{ .title }}</h2>
|
||||||
|
<div class="step-body">{{ replace .body "{iso}" $file | markdownify }}</div>
|
||||||
|
</li>
|
||||||
|
{{ end }}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="dl-section">
|
||||||
|
<p class="section-eyebrow">{{ .Params.verify_label }}</p>
|
||||||
|
{{ with $r.sha256 }}
|
||||||
|
<p>{{ $.Params.verify_intro }}</p>
|
||||||
|
<pre class="dl-hash"><code>{{ . }}</code></pre>
|
||||||
|
<pre class="dl-cmds"><code><span class="c"># Linux</span>
|
||||||
|
sha256sum {{ $file }}
|
||||||
|
<span class="c"># macOS</span>
|
||||||
|
shasum -a 256 {{ $file }}
|
||||||
|
<span class="c"># Windows (PowerShell)</span>
|
||||||
|
Get-FileHash {{ $file }}</code></pre>
|
||||||
|
{{ else }}
|
||||||
|
<p class="dl-muted">{{ $.Params.verify_missing }}</p>
|
||||||
|
{{ end }}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="dl-older"><a href="{{ $r.all }}">{{ .Params.older }} →</a></p>
|
||||||
|
</article>
|
||||||
|
{{ end }}
|
||||||
|
|
@ -1,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 }}
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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 }}
|
||||||
|
|
|
||||||
57
website/layouts/partials/release.html
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
{{- /*
|
||||||
|
Latest Furtka release that has an ISO attached, looked up on Forgejo at
|
||||||
|
build time. Returns a dict: version, url (ISO), size (bytes, 0 = unknown),
|
||||||
|
date (time or ""), sha256 ("" when the release has no .iso.sha256), page
|
||||||
|
(release page URL), live (false when the lookup failed and we fell back
|
||||||
|
to site.Params.version). Use via partialCached "release.html" . "release".
|
||||||
|
*/ -}}
|
||||||
|
{{- $repo := printf "%s/%s" site.Params.forgejoURL site.Params.repoPath -}}
|
||||||
|
{{- $v := site.Params.version -}}
|
||||||
|
{{- $out := dict
|
||||||
|
"version" $v
|
||||||
|
"url" (printf "%s/releases/download/%s/furtka-%s.iso" $repo $v $v)
|
||||||
|
"size" 0
|
||||||
|
"date" ""
|
||||||
|
"sha256" ""
|
||||||
|
"page" (printf "%s/releases/tag/%s" $repo $v)
|
||||||
|
"all" (printf "%s/releases" $repo)
|
||||||
|
"live" false
|
||||||
|
-}}
|
||||||
|
{{- $api := printf "%s/api/v1/repos/%s/releases?limit=10" site.Params.forgejoURL site.Params.repoPath -}}
|
||||||
|
{{- with try (resources.GetRemote $api) -}}
|
||||||
|
{{- with .Err -}}
|
||||||
|
{{- warnf "release.html: release lookup failed, using site.Params.version: %s" . -}}
|
||||||
|
{{- else with .Value -}}
|
||||||
|
{{- $done := false -}}
|
||||||
|
{{- range (. | transform.Unmarshal) -}}
|
||||||
|
{{- if and (not $done) (not .draft) -}}
|
||||||
|
{{- $rel := . -}}
|
||||||
|
{{- $iso := "" -}}
|
||||||
|
{{- $sum := "" -}}
|
||||||
|
{{- range .assets -}}
|
||||||
|
{{- if strings.HasSuffix .name ".iso" }}{{ $iso = . }}{{ end -}}
|
||||||
|
{{- if strings.HasSuffix .name ".iso.sha256" }}{{ $sum = . }}{{ end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- with $iso -}}
|
||||||
|
{{- $done = true -}}
|
||||||
|
{{- $out = merge $out (dict
|
||||||
|
"version" $rel.tag_name
|
||||||
|
"url" .browser_download_url
|
||||||
|
"size" .size
|
||||||
|
"date" (time.AsTime $rel.published_at)
|
||||||
|
"page" $rel.html_url
|
||||||
|
"live" true) -}}
|
||||||
|
{{- with $sum -}}
|
||||||
|
{{- with try (resources.GetRemote .browser_download_url) -}}
|
||||||
|
{{- with .Value -}}
|
||||||
|
{{- $hash := index (split (trim .Content " \n") " ") 0 -}}
|
||||||
|
{{- if eq (len $hash) 64 }}{{ $out = merge $out (dict "sha256" $hash) }}{{ end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- return $out -}}
|
||||||
|
|
@ -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 }}
|
||||||
|
|
|
||||||
BIN
website/static/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
|
|
@ -1,6 +1 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#a33e2e" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><style>svg{color:#c03a28}@media (prefers-color-scheme:dark){svg{color:#ff6b56}}</style><path fill="currentColor" fill-rule="evenodd" d="M5 60V27a27 27 0 0 1 54 0v33Z M19.5 60V26A10 10 0 0 1 29.5 16H46v10H32a2.5 2.5 0 0 0-2.5 2.5V32H46v10H29.5v18Z"/></svg>
|
||||||
<path d="M4 20 V12 A9 9 0 0 1 20 12 V20"/>
|
|
||||||
<line x1="12" y1="5" x2="12" y2="20"/>
|
|
||||||
<line x1="3" y1="20" x2="21" y2="20"/>
|
|
||||||
<line x1="15" y1="12" x2="15" y2="14.5"/>
|
|
||||||
</svg>
|
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 334 B After Width: | Height: | Size: 316 B |
BIN
website/static/img/brand/social.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
104
website/tools/brand-assets.py
Normal 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())
|
||||||