Compare commits
2 commits
850d656169
...
8fbe67ffb9
| Author | SHA1 | Date | |
|---|---|---|---|
| 8fbe67ffb9 | |||
| 9ae14f4108 |
10 changed files with 281 additions and 45 deletions
|
|
@ -106,7 +106,7 @@ None of these nail the "your dad can set this up" experience. The installer wiza
|
||||||
- [x] Release process + CI — CalVer tags, conventional commits, Forgejo Actions (ruff, pytest, JSON, link checks), `26.0-alpha` tagged
|
- [x] Release process + CI — CalVer tags, conventional commits, Forgejo Actions (ruff, pytest, JSON, link checks), `26.0-alpha` tagged
|
||||||
- [x] Forgejo runner live on Proxmox VM (`forge-runner-01`, Ubuntu 24.04) — docker-outside-of-docker with host-mode jobs for ISO builds, setup captured in [docs/runner-setup.md](docs/runner-setup.md) + [ops/forgejo-runner/](ops/forgejo-runner/)
|
- [x] Forgejo runner live on Proxmox VM (`forge-runner-01`, Ubuntu 24.04) — docker-outside-of-docker with host-mode jobs for ISO builds, setup captured in [docs/runner-setup.md](docs/runner-setup.md) + [ops/forgejo-runner/](ops/forgejo-runner/)
|
||||||
- [x] **ISO-build in CI** — `.forgejo/workflows/build-iso.yml` runs `iso/build.sh` on every push to `main` and publishes the resulting `.iso` as the `furtka-iso` artifact (14 d retention). Push → green run → download → test.
|
- [x] **ISO-build in CI** — `.forgejo/workflows/build-iso.yml` runs `iso/build.sh` on every push to `main` and publishes the resulting `.iso` as the `furtka-iso` artifact (14 d retention). Push → green run → download → test.
|
||||||
- [x] **Forgejo Releases + tag-driven release pipeline** — `.forgejo/workflows/release.yml` fires on `[0-9]*` tags, `scripts/build-release-tarball.sh` packages `furtka/` + `apps/` + `assets/` + a root VERSION, `scripts/publish-release.sh` uploads tarball + sha256 + release.json to the Forgejo releases page. `26.1-alpha` and `26.3-alpha` live at [releases](https://forgejo.sourcegate.online/daniel/furtka/releases). Needs one repo secret (`FORGEJO_RELEASE_TOKEN`).
|
- [x] **Forgejo Releases + tag-driven release pipeline** — `.forgejo/workflows/release.yml` fires on `[0-9]*` tags, `scripts/build-release-tarball.sh` packages `furtka/` + `apps/` + `assets/` + a root VERSION, `scripts/publish-release.sh` uploads tarball + sha256 + release.json to the Forgejo releases page. Releases `26.1-alpha`, `26.3-alpha`, and `26.4-alpha` live at [releases](https://forgejo.sourcegate.online/daniel/furtka/releases) (26.2 stalled on a `jq` apt hang, fixed in 26.3). Needs one repo secret (`FORGEJO_RELEASE_TOKEN`).
|
||||||
- [x] **Walking-skeleton live ISO — end to end** — `iso/build.sh` produces a hybrid BIOS/UEFI Arch-based ISO. It boots in a Proxmox VM, DHCPs onto the LAN, shows a console welcome with `http://proksi.local:5000` (+ IP fallback), serves the Flask webinstaller, runs `archinstall --silent`, reboots the VM via a Reboot-now button, and the installed system logs in and runs `docker ps` without sudo. Build infra in [`iso/`](iso/).
|
- [x] **Walking-skeleton live ISO — end to end** — `iso/build.sh` produces a hybrid BIOS/UEFI Arch-based ISO. It boots in a Proxmox VM, DHCPs onto the LAN, shows a console welcome with `http://proksi.local:5000` (+ IP fallback), serves the Flask webinstaller, runs `archinstall --silent`, reboots the VM via a Reboot-now button, and the installed system logs in and runs `docker ps` without sudo. Build infra in [`iso/`](iso/).
|
||||||
- [x] **Drop loop/rom devices from drive list** — `webinstaller/drives.py` filters by `lsblk` `TYPE=disk`, so the live squashfs and CD-ROM no longer appear as install targets. Boot-USB filtering on bare metal is still TODO; see [iso/README.md](iso/README.md).
|
- [x] **Drop loop/rom devices from drive list** — `webinstaller/drives.py` filters by `lsblk` `TYPE=disk`, so the live squashfs and CD-ROM no longer appear as install targets. Boot-USB filtering on bare metal is still TODO; see [iso/README.md](iso/README.md).
|
||||||
- [x] **Rebrand GRUB menu** — `iso/build.sh` rewrites "Arch Linux install medium" → "Furtka Live Installer" across GRUB, syslinux, and systemd-boot configs; default entry marked `(Recommended)`.
|
- [x] **Rebrand GRUB menu** — `iso/build.sh` rewrites "Arch Linux install medium" → "Furtka Live Installer" across GRUB, syslinux, and systemd-boot configs; default entry marked `(Recommended)`.
|
||||||
|
|
@ -117,8 +117,10 @@ None of these nail the "your dad can set this up" experience. The installer wiza
|
||||||
- [x] **On-box web UI uplevel** — shared `/style.css` served by Caddy, persistent top nav, landing page with an "Your apps" tile grid + live status, `/apps` with real per-app icons (inlined SVG from each manifest), new `/settings` page (hostname, IP, version, kernel, RAM, Docker, uptime + Furtka-updates card). `prefers-color-scheme` light/dark.
|
- [x] **On-box web UI uplevel** — shared `/style.css` served by Caddy, persistent top nav, landing page with an "Your apps" tile grid + live status, `/apps` with real per-app icons (inlined SVG from each manifest), new `/settings` page (hostname, IP, version, kernel, RAM, Docker, uptime + Furtka-updates card). `prefers-color-scheme` light/dark.
|
||||||
- [x] **Versioned on-box layout + Phase 1 per-app updates** — `/opt/furtka/versions/<ver>/` + `current` symlink; `/var/lib/furtka/` for runtime state. `POST /api/apps/<name>/update` runs `docker compose pull` + compares digests + conditional `up -d`.
|
- [x] **Versioned on-box layout + Phase 1 per-app updates** — `/opt/furtka/versions/<ver>/` + `current` symlink; `/var/lib/furtka/` for runtime state. `POST /api/apps/<name>/update` runs `docker compose pull` + compares digests + conditional `up -d`.
|
||||||
- [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` alongside plain `:80`. Per-box root CA generated on first start, `rootCA.crt` downloadable from `/settings`, per-OS install guide at `/https-install/`. Opt-in "force HTTPS" toggle only exposes itself once the current browser already trusts the cert, so enabling it can't lock the user out. Shipped in 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.
|
||||||
- [ ] 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.
|
||||||
- [ ] `https://proksi.local` with a local CA (today: plain HTTP at `http://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 infrastructure — `ns1/ns2.furtka.org` + DNS-01 wildcard automation
|
||||||
- [ ] First containerized service (Nextcloud?) with auto-SSO + auto-subdomain
|
- [ ] First containerized service (Nextcloud?) with auto-SSO + auto-subdomain
|
||||||
|
|
|
||||||
113
apps/README.md
Normal file
113
apps/README.md
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
# Building a Furtka app from a Docker image
|
||||||
|
|
||||||
|
A Furtka app is a folder with four files. The reconciler walks `/var/lib/furtka/apps/*` at boot, validates each manifest, ensures the declared volumes exist, and runs `docker compose up -d` per app. Filesystem is the only source of truth — no database.
|
||||||
|
|
||||||
|
Use `apps/fileshare/` as the reference implementation.
|
||||||
|
|
||||||
|
## Folder layout
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/<name>/
|
||||||
|
manifest.json # required — app metadata and user-facing settings
|
||||||
|
docker-compose.yaml # required — filename is .yaml, not .yml
|
||||||
|
.env.example # required — keys consumed by docker-compose, with safe defaults
|
||||||
|
icon.svg # required — referenced by manifest.icon
|
||||||
|
```
|
||||||
|
|
||||||
|
The folder name must equal `manifest.name`. The scanner rejects mismatches.
|
||||||
|
|
||||||
|
## `manifest.json`
|
||||||
|
|
||||||
|
All top-level fields except `description_long` and `settings` are required.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "myapp",
|
||||||
|
"display_name": "My App",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "One-line summary shown in the app list.",
|
||||||
|
"description_long": "Longer German prose shown on the app page. Optional.",
|
||||||
|
"volumes": ["data"],
|
||||||
|
"ports": [8080],
|
||||||
|
"icon": "icon.svg",
|
||||||
|
"settings": [
|
||||||
|
{
|
||||||
|
"name": "ADMIN_PASSWORD",
|
||||||
|
"label": "Passwort",
|
||||||
|
"description": "Wird beim ersten Start gesetzt.",
|
||||||
|
"type": "password",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules enforced by `furtka/manifest.py`:
|
||||||
|
|
||||||
|
- `volumes` — short names, strings. Namespaced to `furtka_<app>_<short>` at runtime.
|
||||||
|
- `ports` — integers. Informational only; compose owns the actual port binding.
|
||||||
|
- `settings[].name` — must match `^[A-Z_][A-Z0-9_]*$`. This name becomes both the env-var key and the form-field ID.
|
||||||
|
- `settings[].type` — one of `text`, `password`, `number`.
|
||||||
|
- `settings[].required` — if true, the install refuses when the value is empty.
|
||||||
|
- `settings[].default` — optional string. Used to pre-fill the form and the bootstrapped `.env`.
|
||||||
|
|
||||||
|
## `docker-compose.yaml`
|
||||||
|
|
||||||
|
- File extension is `.yaml`. The compose runner hardcodes this — `.yml` will not be found.
|
||||||
|
- Reference manifest volumes as `furtka_<app>_<short>` with `external: true`. The reconciler creates the volume *before* `compose up`, so compose must not try to manage its lifecycle.
|
||||||
|
- Values from `.env` are substituted by compose in the usual `${VAR}` form.
|
||||||
|
- If the upstream image ships a HEALTHCHECK that misbehaves on Furtka's setup, disable it — a permanently-unhealthy container scares users reading `docker ps`.
|
||||||
|
- Pin images to a digest or stable tag when you can. `:latest` is acceptable for an MVP but noisy.
|
||||||
|
|
||||||
|
Minimal example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: ghcr.io/example/myapp:1.2.3
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- furtka_myapp_data:/var/lib/myapp
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
furtka_myapp_data:
|
||||||
|
external: true
|
||||||
|
```
|
||||||
|
|
||||||
|
## `.env.example`
|
||||||
|
|
||||||
|
One `KEY=VALUE` per line. Every key declared in `manifest.settings` should have a line here so the compose file resolves cleanly on first install even before the user opens the form.
|
||||||
|
|
||||||
|
Do not use `changeme` (or any value listed in `furtka.installer.PLACEHOLDER_SECRETS`) as the default for a required secret. The install step scans the final `.env` and refuses to finish if a placeholder survives — this is the guardrail that stops us shipping an app with a known password.
|
||||||
|
|
||||||
|
For non-secret values (usernames, paths), sensible defaults are fine and go straight into `.env` on first install.
|
||||||
|
|
||||||
|
## `icon.svg`
|
||||||
|
|
||||||
|
- 64×64 viewBox, no width/height attributes so the UI can scale it.
|
||||||
|
- Use `fill="currentColor"` (and `stroke="currentColor"`) so the icon picks up the current theme instead of baking in a color.
|
||||||
|
- Keep it single-path-ish. These render small in the app grid.
|
||||||
|
- The icon is inlined into the `/apps` page by the defensive SVG sanitiser, which strips `<script>`, `on*` attributes, and `javascript:` refs and enforces a 16 KB cap. Anything fancier than static paths and shapes will be rejected.
|
||||||
|
|
||||||
|
## Install and test
|
||||||
|
|
||||||
|
From the repo root on a dev box with Furtka installed:
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo furtka app install ./apps/myapp
|
||||||
|
```
|
||||||
|
|
||||||
|
`furtka app install` runs a reconcile as its last step, so the container is up once the command returns. Open the Web UI (`http://furtka.local/`), fill in the settings form, and confirm the app starts. `docker ps` should show one container per compose service; `docker volume ls` should show `furtka_myapp_*`.
|
||||||
|
|
||||||
|
To bundle the app into the ISO, drop the folder into `apps/` before `iso/build.sh` runs — the build tarballs the whole `apps/` tree into the image.
|
||||||
|
|
||||||
|
## Out of scope (for now)
|
||||||
|
|
||||||
|
- Sharing volumes between apps. v1 keeps them isolated.
|
||||||
|
- Auth on the Web UI. The UI itself has a banner about this.
|
||||||
|
- Automatic updates. User-triggered per-app update is `POST /api/apps/<name>/update`.
|
||||||
|
- A network catalog. `furtka app install <name>` only resolves bundled apps in `/opt/furtka/apps/`.
|
||||||
|
|
@ -1,18 +1,33 @@
|
||||||
# Serves the Furtka landing page + live JSON on :80 (plain HTTP) and :443
|
# Serves the Furtka landing page + live JSON on :80 (plain HTTP) and on
|
||||||
# (HTTPS via Caddy's built-in `tls internal` — locally-issued certs signed
|
# HTTPS via Caddy's built-in `tls internal` — locally-issued certs signed
|
||||||
# by a root CA that Caddy generates on first start and stores under
|
# by a root CA that Caddy generates on first start and stores under
|
||||||
# /var/lib/caddy/.local/share/caddy/pki/authorities/local/). Static pages
|
# /var/lib/caddy/pki/authorities/local/. Static pages are read from
|
||||||
# are read from /opt/furtka/current/ — updates flip the symlink and
|
# /opt/furtka/current/ — updates flip the symlink and everything picks up
|
||||||
# everything picks up the new content without a Caddy restart (a
|
# the new content without a Caddy restart (a `systemctl reload caddy` is
|
||||||
# `systemctl reload caddy` is still triggered post-swap to flush the
|
# still triggered post-swap to flush the file-server's handle cache).
|
||||||
# file-server's handle cache). /apps and /api are reverse-proxied to the
|
# /apps and /api are reverse-proxied to the resource-manager API
|
||||||
# resource-manager API (furtka serve, bound to 127.0.0.1:7000).
|
# (furtka serve, bound to 127.0.0.1:7000).
|
||||||
|
#
|
||||||
|
# Hostname templating: __FURTKA_HOSTNAME__ gets substituted with the
|
||||||
|
# install-time hostname by webinstaller/app.py on first install and by
|
||||||
|
# furtka.updater._refresh_caddyfile on every self-update. A bare `:443
|
||||||
|
# { tls internal }` (no hostname) never triggers leaf-cert issuance, so
|
||||||
|
# SNI-based handshakes die with `SSL_ERROR_INTERNAL_ERROR_ALERT` — the
|
||||||
|
# 26.4-alpha regression this file exists to cure.
|
||||||
#
|
#
|
||||||
# Force-HTTPS: /etc/caddy/furtka.d/*.caddyfile gets imported into the :80
|
# Force-HTTPS: /etc/caddy/furtka.d/*.caddyfile gets imported into the :80
|
||||||
# block. The /api/furtka/https/force endpoint creates or removes
|
# block. The /api/furtka/https/force endpoint creates or removes
|
||||||
# redirect.caddyfile there to toggle the HTTP→HTTPS redirect, then reloads
|
# redirect.caddyfile there to toggle the HTTP→HTTPS redirect, then reloads
|
||||||
# Caddy. Glob imports silently no-op on an empty/missing directory, so the
|
# Caddy. Glob imports silently no-op on an empty/missing directory, so the
|
||||||
# toggle-off state is "no file present" rather than "empty file".
|
# toggle-off state is "no file present" rather than "empty file".
|
||||||
|
{
|
||||||
|
# Named-hostname :443 blocks would otherwise make Caddy add its own
|
||||||
|
# HTTP→HTTPS redirect — but we already serve our own `:80` block and
|
||||||
|
# the opt-in /settings toggle owns the redirect. Disable the built-in
|
||||||
|
# to keep a single source of truth.
|
||||||
|
auto_https disable_redirects
|
||||||
|
}
|
||||||
|
|
||||||
(furtka_routes) {
|
(furtka_routes) {
|
||||||
handle /api/* {
|
handle /api/* {
|
||||||
reverse_proxy localhost:7000
|
reverse_proxy localhost:7000
|
||||||
|
|
@ -38,7 +53,7 @@
|
||||||
# Available on both :80 and :443 so users can grab it before they've
|
# Available on both :80 and :443 so users can grab it before they've
|
||||||
# trusted it. The private key next to it stays 0600 / caddy-owned.
|
# trusted it. The private key next to it stays 0600 / caddy-owned.
|
||||||
handle /rootCA.crt {
|
handle /rootCA.crt {
|
||||||
root * /var/lib/caddy/.local/share/caddy/pki/authorities/local
|
root * /var/lib/caddy/pki/authorities/local
|
||||||
rewrite * /root.crt
|
rewrite * /root.crt
|
||||||
file_server
|
file_server
|
||||||
header Content-Type "application/x-x509-ca-cert"
|
header Content-Type "application/x-x509-ca-cert"
|
||||||
|
|
@ -59,7 +74,7 @@
|
||||||
import furtka_routes
|
import furtka_routes
|
||||||
}
|
}
|
||||||
|
|
||||||
:443 {
|
__FURTKA_HOSTNAME__.local, __FURTKA_HOSTNAME__ {
|
||||||
tls internal
|
tls internal
|
||||||
import furtka_routes
|
import furtka_routes
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
"""Local-CA HTTPS helpers for the `tls internal` setup.
|
"""Local-CA HTTPS helpers for the `tls internal` setup.
|
||||||
|
|
||||||
Caddy generates the local root CA lazily on first start and keeps it under
|
Caddy generates the local root CA lazily on first start and keeps it under
|
||||||
$XDG_DATA_HOME/caddy/pki/authorities/local/ — on the target that's
|
$XDG_DATA_HOME/caddy/pki/authorities/local/ — our packaged caddy.service
|
||||||
/var/lib/caddy/.local/share/caddy/pki/authorities/local/ (the caddy system
|
sets `XDG_DATA_HOME=/var/lib`, so on the target that resolves to
|
||||||
user's XDG_DATA_HOME resolves there). The private key stays 0600 /
|
/var/lib/caddy/pki/authorities/local/. The private key stays 0600 /
|
||||||
caddy-owned; we only ever read the public root.crt next to it.
|
caddy-owned; we only ever read the public root.crt next to it.
|
||||||
|
|
||||||
This module exposes two operations:
|
This module exposes two operations:
|
||||||
|
|
@ -18,7 +18,7 @@ import re
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
CA_CERT_PATH = Path("/var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt")
|
CA_CERT_PATH = Path("/var/lib/caddy/pki/authorities/local/root.crt")
|
||||||
SNIPPET_DIR = Path("/etc/caddy/furtka.d")
|
SNIPPET_DIR = Path("/etc/caddy/furtka.d")
|
||||||
REDIRECT_SNIPPET = SNIPPET_DIR / "redirect.caddyfile"
|
REDIRECT_SNIPPET = SNIPPET_DIR / "redirect.caddyfile"
|
||||||
REDIRECT_CONTENT = "redir https://{host}{uri} permanent\n"
|
REDIRECT_CONTENT = "redir https://{host}{uri} permanent\n"
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,8 @@ _CADDY_SNIPPET_DIR = Path(
|
||||||
os.environ.get("FURTKA_CADDY_SNIPPET_DIR", str(_CADDYFILE_LIVE.parent / "furtka.d"))
|
os.environ.get("FURTKA_CADDY_SNIPPET_DIR", str(_CADDYFILE_LIVE.parent / "furtka.d"))
|
||||||
)
|
)
|
||||||
_SYSTEMD_DIR = Path(os.environ.get("FURTKA_SYSTEMD_DIR", "/etc/systemd/system"))
|
_SYSTEMD_DIR = Path(os.environ.get("FURTKA_SYSTEMD_DIR", "/etc/systemd/system"))
|
||||||
|
_HOSTNAME_FILE = Path(os.environ.get("FURTKA_HOSTNAME_FILE", "/etc/hostname"))
|
||||||
|
_CADDYFILE_HOSTNAME_MARKER = "__FURTKA_HOSTNAME__"
|
||||||
|
|
||||||
|
|
||||||
class UpdateError(RuntimeError):
|
class UpdateError(RuntimeError):
|
||||||
|
|
@ -216,19 +218,38 @@ def _extract_tarball(tarball: Path, dest: Path) -> str:
|
||||||
return version_file.read_text().strip()
|
return version_file.read_text().strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _current_hostname() -> str:
|
||||||
|
"""Read the box's hostname from /etc/hostname, falling back to 'furtka'.
|
||||||
|
|
||||||
|
Used to substitute the __FURTKA_HOSTNAME__ marker in the shipped Caddyfile
|
||||||
|
so Caddy's `tls internal` sees a real name to issue a leaf cert for.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
name = _HOSTNAME_FILE.read_text().strip()
|
||||||
|
except (FileNotFoundError, PermissionError, OSError):
|
||||||
|
return "furtka"
|
||||||
|
return name or "furtka"
|
||||||
|
|
||||||
|
|
||||||
def _refresh_caddyfile(source: Path) -> bool:
|
def _refresh_caddyfile(source: Path) -> bool:
|
||||||
"""Copy the shipped Caddyfile to /etc/caddy/ iff it differs. Returns True
|
"""Copy the shipped Caddyfile to /etc/caddy/ iff it differs. Returns True
|
||||||
if the file changed (so caddy needs more than a bare reload)."""
|
if the file changed (so caddy needs more than a bare reload).
|
||||||
|
|
||||||
|
Substitutes __FURTKA_HOSTNAME__ with the current hostname before comparing
|
||||||
|
and writing — same rendering the webinstaller applies at install time, so
|
||||||
|
a self-update lands byte-identical content when nothing else changed.
|
||||||
|
"""
|
||||||
if not source.is_file():
|
if not source.is_file():
|
||||||
return False
|
return False
|
||||||
# Snippet dir for the /api/furtka/https/force toggle. Pre-HTTPS installs
|
# Snippet dir for the /api/furtka/https/force toggle. Pre-HTTPS installs
|
||||||
# don't have this dir; ensure it so the Caddyfile's glob import can't
|
# don't have this dir; ensure it so the Caddyfile's glob import can't
|
||||||
# trip an older Caddy on a missing path during the first reload.
|
# trip an older Caddy on a missing path during the first reload.
|
||||||
_CADDY_SNIPPET_DIR.mkdir(mode=0o755, parents=True, exist_ok=True)
|
_CADDY_SNIPPET_DIR.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||||
if _CADDYFILE_LIVE.is_file() and source.read_bytes() == _CADDYFILE_LIVE.read_bytes():
|
rendered = source.read_text().replace(_CADDYFILE_HOSTNAME_MARKER, _current_hostname())
|
||||||
|
if _CADDYFILE_LIVE.is_file() and rendered == _CADDYFILE_LIVE.read_text():
|
||||||
return False
|
return False
|
||||||
_CADDYFILE_LIVE.parent.mkdir(parents=True, exist_ok=True)
|
_CADDYFILE_LIVE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
shutil.copy(source, _CADDYFILE_LIVE)
|
_CADDYFILE_LIVE.write_text(rendered)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,15 +21,18 @@ The script re-execs itself inside a privileged `archlinux:latest` container. Tha
|
||||||
The build starts from Arch's stock `releng` profile (the same one used to build the official Arch ISO), then overlays our customizations from `overlay/`:
|
The build starts from Arch's stock `releng` profile (the same one used to build the official Arch ISO), then overlays our customizations from `overlay/`:
|
||||||
|
|
||||||
| Overlay file | Effect |
|
| Overlay file | Effect |
|
||||||
|-------------------------------------------|----------------------------------------------------------------------------------|
|
|----------------------------------------------|----------------------------------------------------------------------------------|
|
||||||
| `overlay/packages.extra` | Appended to the package list. Adds `python`, `python-flask`, `avahi`, `nss-mdns` |
|
| `overlay/packages.extra` | Appended to the package list. Adds `python`, `python-flask`, `avahi`, `nss-mdns` |
|
||||||
| `overlay/profiledef.sh` | Appended to `profiledef.sh`. Renames the ISO to `furtka-*` with a dated version |
|
| `overlay/profiledef.sh` | Appended to `profiledef.sh`. Renames the ISO to `furtka-*` with a dated version |
|
||||||
| `overlay/airootfs/opt/furtka/` | Directory where `webinstaller/` is copied at build time |
|
| `overlay/airootfs/opt/furtka/` | Directory where `webinstaller/` is copied at build time |
|
||||||
| `overlay/airootfs/etc/systemd/system/` | Contains `furtka-webinstaller.service` + a symlink into `multi-user.target.wants/` so it auto-starts on boot |
|
| `overlay/airootfs/etc/hostname` | Live-ISO hostname (`proksi`) so mDNS advertises the installer as `proksi.local` |
|
||||||
|
| `overlay/airootfs/etc/issue` | Welcome banner on the TTY pointing users at `http://proksi.local:5000` |
|
||||||
|
| `overlay/airootfs/usr/local/bin/furtka-update-issue` | Rewrites `/etc/issue` at runtime so the banner also shows the DHCP-assigned IP as a fallback URL |
|
||||||
|
| `overlay/airootfs/etc/systemd/system/` | `furtka-webinstaller.service` (Flask on :5000) + `furtka-issue.service` (runs the banner-updater on network-online), each symlinked into `multi-user.target.wants/` to auto-start on boot |
|
||||||
|
|
||||||
The systemd service runs `flask --app app run --host 0.0.0.0 --port 5000` under `/opt/furtka`. The `0.0.0.0` binding is important — the Flask default is localhost-only, which wouldn't be reachable from another machine on the LAN.
|
The systemd service runs `flask --app app run --host 0.0.0.0 --port 5000` under `/opt/furtka`. The `0.0.0.0` binding is important — the Flask default is localhost-only, which wouldn't be reachable from another machine on the LAN.
|
||||||
|
|
||||||
mDNS (`proksi.local`) via avahi is installed but not yet wired. First milestone is just "boot → browser → wizard at raw IP". Naming comes next.
|
mDNS is wired: `avahi-daemon` + `nss-mdns` come from `packages.extra`, the live ISO's hostname is `proksi`, and as soon as `systemd-networkd-wait-online` fires the installer is reachable at `http://proksi.local:5000`. The raw IP still shows on the console for fallback — some Windows clients need the Bonjour service for `.local` to resolve at all.
|
||||||
|
|
||||||
## Test flow
|
## Test flow
|
||||||
|
|
||||||
|
|
@ -51,7 +54,7 @@ mDNS (`proksi.local`) via avahi is installed but not yet wired. First milestone
|
||||||
Once `archinstall` finishes and you click **Reboot now**, the VM comes up into the installed system. No more port `:5000` — the wizard ISO is gone. Instead:
|
Once `archinstall` finishes and you click **Reboot now**, the VM comes up into the installed system. No more port `:5000` — the wizard ISO is gone. Instead:
|
||||||
|
|
||||||
- **Console**: agetty shows `Furtka is ready. Open http://<hostname>.local …` with the IP fallback underneath.
|
- **Console**: agetty shows `Furtka is ready. Open http://<hostname>.local …` with the IP fallback underneath.
|
||||||
- **Browser** at `http://<hostname>.local` (default `http://proksi.local`): Caddy-served landing page with three live status tiles (uptime, Docker version, free disk) refreshed every 30 s by `furtka-status.timer`.
|
- **Browser** at `http://<hostname>.local` (default `http://furtka.local` — the form's default hostname is `furtka`; only the live-installer ISO uses `proksi`): Caddy-served landing page with three live status tiles (uptime, Docker version, free disk) refreshed every 30 s by `furtka-status.timer`. Since 26.4-alpha, `https://<hostname>.local` is also served via Caddy's `tls internal` — trust `rootCA.crt` from `/settings` to clear browser warnings.
|
||||||
- **SSH**: `ssh <user>@<hostname>.local` works; `docker ps` works without `sudo` because the user is in the `docker` group.
|
- **SSH**: `ssh <user>@<hostname>.local` works; `docker ps` works without `sudo` because the user is in the `docker` group.
|
||||||
|
|
||||||
This is a demo shell — no Authentik, no app store yet. The landing page lives at `/srv/furtka/www/`, served by Caddy on `:80` per `/etc/caddy/Caddyfile`. All of this is written into the target by `webinstaller/app.py`'s `_post_install_commands` via archinstall's `custom_commands`.
|
This is a demo shell — no Authentik, no app store yet. The landing page lives at `/srv/furtka/www/`, served by Caddy on `:80` per `/etc/caddy/Caddyfile`. All of this is written into the target by `webinstaller/app.py`'s `_post_install_commands` via archinstall's `custom_commands`.
|
||||||
|
|
@ -59,5 +62,5 @@ This is a demo shell — no Authentik, no app store yet. The landing page lives
|
||||||
## Known rough edges
|
## Known rough edges
|
||||||
|
|
||||||
- **Disk space**: the first time you build on a fresh host, the squashfs/xorriso steps need ~15 GB free. If the host's LVM-root is smaller, `xorriso` silently dies at the very end with "Image size exceeds free space on media".
|
- **Disk space**: the first time you build on a fresh host, the squashfs/xorriso steps need ~15 GB free. If the host's LVM-root is smaller, `xorriso` silently dies at the very end with "Image size exceeds free space on media".
|
||||||
- **No HTTPS yet**. The Furtka plan is "local CA + green padlock on `https://proksi.local`" — that's a later milestone. For now, plain HTTP.
|
- **Live-installer wizard is still HTTP-only**. `http://proksi.local:5000` during install has no TLS; the installed box gets Caddy + `tls internal` on `:443` once it reboots (26.4-alpha), but bringing the same story to the wizard itself is a later milestone.
|
||||||
- **Boot USB could appear as an install target on bare metal**. On a VM the ISO is a CD-ROM (filtered) and SATA is the only disk, so the picker only shows the install target. On bare metal with a USB stick, the USB is `TYPE=disk` and shows up alongside the real install drive; a user could in theory pick the USB they just booted from. Mitigating this needs detecting the boot media (via `findmnt /run/archiso/bootmnt` or similar) and filtering it out in `webinstaller/drives.py`.
|
- **Boot USB could appear as an install target on bare metal**. On a VM the ISO is a CD-ROM (filtered) and SATA is the only disk, so the picker only shows the install target. On bare metal with a USB stick, the USB is `TYPE=disk` and shows up alongside the real install drive; a user could in theory pick the USB they just booted from. Mitigating this needs detecting the boot media (via `findmnt /run/archiso/bootmnt` or similar) and filtering it out in `webinstaller/drives.py`.
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@ def updater(tmp_path, monkeypatch):
|
||||||
monkeypatch.setenv("FURTKA_LOCK_PATH", str(tmp_path / "update.lock"))
|
monkeypatch.setenv("FURTKA_LOCK_PATH", str(tmp_path / "update.lock"))
|
||||||
monkeypatch.setenv("FURTKA_CADDYFILE_PATH", str(tmp_path / "etc_caddy" / "Caddyfile"))
|
monkeypatch.setenv("FURTKA_CADDYFILE_PATH", str(tmp_path / "etc_caddy" / "Caddyfile"))
|
||||||
monkeypatch.setenv("FURTKA_SYSTEMD_DIR", str(tmp_path / "etc_systemd_system"))
|
monkeypatch.setenv("FURTKA_SYSTEMD_DIR", str(tmp_path / "etc_systemd_system"))
|
||||||
|
hostname_file = tmp_path / "etc_hostname"
|
||||||
|
hostname_file.write_text("testbox\n")
|
||||||
|
monkeypatch.setenv("FURTKA_HOSTNAME_FILE", str(hostname_file))
|
||||||
(tmp_path / "etc_systemd_system").mkdir()
|
(tmp_path / "etc_systemd_system").mkdir()
|
||||||
# Reload the module so the path constants pick up the env vars.
|
# Reload the module so the path constants pick up the env vars.
|
||||||
import importlib
|
import importlib
|
||||||
|
|
@ -206,6 +209,31 @@ def test_refresh_caddyfile_noops_if_source_missing(updater, tmp_path):
|
||||||
assert updater._refresh_caddyfile(tmp_path / "does-not-exist") is False
|
assert updater._refresh_caddyfile(tmp_path / "does-not-exist") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_caddyfile_substitutes_hostname_placeholder(updater, tmp_path):
|
||||||
|
# Self-update rewrites the shipped Caddyfile against the box's real
|
||||||
|
# hostname, same substitution the installer does on first boot. Without
|
||||||
|
# this the named-hostname :443 block ships with a literal
|
||||||
|
# `__FURTKA_HOSTNAME__` and Caddy refuses to load the config.
|
||||||
|
src = tmp_path / "src"
|
||||||
|
src.write_text(
|
||||||
|
"__FURTKA_HOSTNAME__.local, __FURTKA_HOSTNAME__ {\n\ttls internal\n}\n"
|
||||||
|
)
|
||||||
|
assert updater._refresh_caddyfile(src) is True
|
||||||
|
live = updater._CADDYFILE_LIVE.read_text()
|
||||||
|
assert "testbox.local, testbox {" in live
|
||||||
|
assert "__FURTKA_HOSTNAME__" not in live
|
||||||
|
# Second call with the same source is a no-op — rendered content matches.
|
||||||
|
assert updater._refresh_caddyfile(src) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_hostname_falls_back_when_file_missing(updater, monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("FURTKA_HOSTNAME_FILE", str(tmp_path / "missing"))
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
importlib.reload(updater)
|
||||||
|
assert updater._current_hostname() == "furtka"
|
||||||
|
|
||||||
|
|
||||||
def test_link_new_units_only_links_missing(updater, tmp_path, monkeypatch):
|
def test_link_new_units_only_links_missing(updater, tmp_path, monkeypatch):
|
||||||
unit_dir = tmp_path / "assets_systemd"
|
unit_dir = tmp_path / "assets_systemd"
|
||||||
unit_dir.mkdir()
|
unit_dir.mkdir()
|
||||||
|
|
|
||||||
|
|
@ -31,9 +31,10 @@ ASSETS = REPO_ROOT / "assets"
|
||||||
|
|
||||||
# (install target path, asset path under furtka/assets/) — only the files we
|
# (install target path, asset path under furtka/assets/) — only the files we
|
||||||
# still copy bit-for-bit at install time. Scripts + unit files are no longer
|
# still copy bit-for-bit at install time. Scripts + unit files are no longer
|
||||||
# copied; they're reached via /opt/furtka/current and `systemctl link`.
|
# copied; they're reached via /opt/furtka/current and `systemctl link`. The
|
||||||
|
# Caddyfile is not in this list because it's written with the hostname
|
||||||
|
# placeholder substituted — see test_post_install_substitutes_hostname_in_caddyfile.
|
||||||
ASSET_TARGETS = [
|
ASSET_TARGETS = [
|
||||||
("/etc/caddy/Caddyfile", "Caddyfile"),
|
|
||||||
("/var/lib/furtka/status.json", "www/status.json"),
|
("/var/lib/furtka/status.json", "www/status.json"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -123,11 +124,12 @@ def test_caddyfile_asset_serves_from_current():
|
||||||
|
|
||||||
def test_caddyfile_serves_both_http_and_https():
|
def test_caddyfile_serves_both_http_and_https():
|
||||||
# :80 stays so users who haven't installed the CA still reach the box;
|
# :80 stays so users who haven't installed the CA still reach the box;
|
||||||
# :443 uses Caddy's built-in local CA (tls internal) so users who have
|
# HTTPS is served via a named-hostname block so Caddy's `tls internal`
|
||||||
# installed it get the green padlock.
|
# has something to issue a leaf cert for. A bare `:443 { tls internal }`
|
||||||
|
# never triggers issuance — that was the 26.4-alpha regression.
|
||||||
caddy = (ASSETS / "Caddyfile").read_text()
|
caddy = (ASSETS / "Caddyfile").read_text()
|
||||||
assert ":80 {" in caddy
|
assert ":80 {" in caddy
|
||||||
assert ":443 {" in caddy
|
assert "__FURTKA_HOSTNAME__.local, __FURTKA_HOSTNAME__ {" in caddy
|
||||||
assert "tls internal" in caddy
|
assert "tls internal" in caddy
|
||||||
# Shared routes live in a named snippet to avoid drift between the two
|
# Shared routes live in a named snippet to avoid drift between the two
|
||||||
# listeners — both site blocks must import it.
|
# listeners — both site blocks must import it.
|
||||||
|
|
@ -135,6 +137,14 @@ def test_caddyfile_serves_both_http_and_https():
|
||||||
assert caddy.count("import furtka_routes") == 2
|
assert caddy.count("import furtka_routes") == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_caddyfile_disables_caddy_auto_redirects():
|
||||||
|
# Named-hostname :443 block makes Caddy want to add its own HTTP→HTTPS
|
||||||
|
# redirect. The /settings toggle is the single source of truth, so the
|
||||||
|
# built-in has to be off — otherwise the toggle and auto_https race.
|
||||||
|
caddy = (ASSETS / "Caddyfile").read_text()
|
||||||
|
assert "auto_https disable_redirects" in caddy
|
||||||
|
|
||||||
|
|
||||||
def test_caddyfile_imports_force_redirect_snippet_dir():
|
def test_caddyfile_imports_force_redirect_snippet_dir():
|
||||||
# The /api/furtka/https/force endpoint toggles HTTP→HTTPS by writing or
|
# The /api/furtka/https/force endpoint toggles HTTP→HTTPS by writing or
|
||||||
# removing a snippet file in this dir; the Caddyfile must glob-import it
|
# removing a snippet file in this dir; the Caddyfile must glob-import it
|
||||||
|
|
@ -146,13 +156,31 @@ def test_caddyfile_imports_force_redirect_snippet_dir():
|
||||||
def test_caddyfile_exposes_root_ca_download():
|
def test_caddyfile_exposes_root_ca_download():
|
||||||
# /rootCA.crt is the download handle the UI uses. It must map to the
|
# /rootCA.crt is the download handle the UI uses. It must map to the
|
||||||
# Caddy local-CA pki path and set a Content-Disposition so the browser
|
# Caddy local-CA pki path and set a Content-Disposition so the browser
|
||||||
# treats it as a download rather than trying to render it.
|
# treats it as a download rather than trying to render it. Path is the
|
||||||
|
# real one Caddy uses under XDG_DATA_HOME=/var/lib (see caddy.service
|
||||||
|
# Environment= directive) — not the /var/lib/caddy/.local/share/caddy/
|
||||||
|
# path Caddy docs show for non-systemd installs.
|
||||||
caddy = (ASSETS / "Caddyfile").read_text()
|
caddy = (ASSETS / "Caddyfile").read_text()
|
||||||
assert "handle /rootCA.crt" in caddy
|
assert "handle /rootCA.crt" in caddy
|
||||||
assert "/var/lib/caddy/.local/share/caddy/pki/authorities/local" in caddy
|
assert "/var/lib/caddy/pki/authorities/local" in caddy
|
||||||
|
assert ".local/share/caddy" not in caddy
|
||||||
assert "attachment; filename=furtka-local-rootCA.crt" in caddy
|
assert "attachment; filename=furtka-local-rootCA.crt" in caddy
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_install_substitutes_hostname_in_caddyfile(install_cmds):
|
||||||
|
# Fresh installs: the placeholder the asset ships with must be replaced
|
||||||
|
# with the hostname the user picked in the form. The `testhost` value
|
||||||
|
# comes from the install_cmds fixture. Without substitution Caddy's
|
||||||
|
# `tls internal` never issues a leaf cert for the real hostname.
|
||||||
|
caddyfile_cmd = next(
|
||||||
|
(c for c in install_cmds if " > /etc/caddy/Caddyfile" in c), None
|
||||||
|
)
|
||||||
|
assert caddyfile_cmd is not None
|
||||||
|
written = _extract_written_content(caddyfile_cmd, "/etc/caddy/Caddyfile")
|
||||||
|
assert "__FURTKA_HOSTNAME__" not in written
|
||||||
|
assert "testhost.local, testhost {" in written
|
||||||
|
|
||||||
|
|
||||||
def test_post_install_creates_furtka_d_snippet_dir(install_cmds):
|
def test_post_install_creates_furtka_d_snippet_dir(install_cmds):
|
||||||
# Pre-existing installs pick up the import path via updater._refresh_caddyfile,
|
# Pre-existing installs pick up the import path via updater._refresh_caddyfile,
|
||||||
# but fresh installs never run that — this command is the only guarantee
|
# but fresh installs never run that — this command is the only guarantee
|
||||||
|
|
|
||||||
|
|
@ -331,7 +331,16 @@ def _post_install_commands(hostname):
|
||||||
# (systemd unit points there). Content comes from the shipped asset,
|
# (systemd unit points there). Content comes from the shipped asset,
|
||||||
# which we copy in at install time so updates that change routing
|
# which we copy in at install time so updates that change routing
|
||||||
# need a new release to refresh it.
|
# need a new release to refresh it.
|
||||||
_write_file_cmd("/etc/caddy/Caddyfile", _read_asset("Caddyfile")),
|
#
|
||||||
|
# __FURTKA_HOSTNAME__ is the placeholder the asset carries in place
|
||||||
|
# of the real hostname — Caddy's `tls internal` needs a named site
|
||||||
|
# block to issue a leaf cert, and the hostname isn't known until
|
||||||
|
# the user fills in the form. Self-updates re-apply the same
|
||||||
|
# substitution against /etc/hostname (see updater._refresh_caddyfile).
|
||||||
|
_write_file_cmd(
|
||||||
|
"/etc/caddy/Caddyfile",
|
||||||
|
_read_asset("Caddyfile").replace("__FURTKA_HOSTNAME__", hostname),
|
||||||
|
),
|
||||||
# Initial status.json so Caddy doesn't 404 before furtka-status fires.
|
# Initial status.json so Caddy doesn't 404 before furtka-status fires.
|
||||||
_write_file_cmd("/var/lib/furtka/status.json", _read_asset("www/status.json")),
|
_write_file_cmd("/var/lib/furtka/status.json", _read_asset("www/status.json")),
|
||||||
nss_sed,
|
nss_sed,
|
||||||
|
|
|
||||||
|
|
@ -19,21 +19,37 @@ Hosted on `forge-runner-01` (Proxmox VM, Ubuntu 24.04). Hugo runs on the VM;
|
||||||
nginx serves the built output from `/var/www/furtka.org`. TLS is terminated by
|
nginx serves the built output from `/var/www/furtka.org`. TLS is terminated by
|
||||||
an upstream openresty reverse proxy — the VM itself only speaks plain HTTP.
|
an upstream openresty reverse proxy — the VM itself only speaks plain HTTP.
|
||||||
|
|
||||||
First time only, on the VM:
|
### Auto-deploy on push-to-main (default)
|
||||||
|
|
||||||
```sh
|
`.forgejo/workflows/deploy-site.yml` fires on every push to `main` that touches
|
||||||
ssh forge-runner
|
`website/**`. The self-hosted runner *is* forge-runner-01, so the whole deploy
|
||||||
sudo /srv/furtka-site/ops/nginx/setup-vm.sh # or copy the script over first
|
collapses to a local rsync into `/srv/furtka-site/` + `hugo --minify` into
|
||||||
```
|
`/var/www/furtka.org/`. No SSH hop, no secrets. Runs in under a minute.
|
||||||
|
|
||||||
From then on, deploy from your dev machine:
|
The in-CI script is `website/deploy-ci.sh`. Don't invoke it from your dev box —
|
||||||
|
it assumes it's already on the target host.
|
||||||
|
|
||||||
|
### Manual deploy (fallback)
|
||||||
|
|
||||||
|
For out-of-band pushes (feature branch, CI outage), deploy from your dev
|
||||||
|
machine:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./website/deploy.sh
|
./website/deploy.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The script rsyncs `website/` to `/srv/furtka-site/` on the VM and runs
|
This rsyncs `website/` to `/srv/furtka-site/` on the VM over SSH and runs
|
||||||
`hugo --minify` into `/var/www/furtka.org`.
|
`hugo --minify` into `/var/www/furtka.org`. Same end state as the CI path,
|
||||||
|
just with an SSH hop.
|
||||||
|
|
||||||
|
### First-time VM setup
|
||||||
|
|
||||||
|
Only needed once, when provisioning a fresh forge-runner VM:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh forge-runner
|
||||||
|
sudo /srv/furtka-site/ops/nginx/setup-vm.sh # or copy the script over first
|
||||||
|
```
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
|
|
@ -48,7 +64,8 @@ layouts/ Custom inline theme — no external theme or framework
|
||||||
index.html Home-only layout with editorial hero
|
index.html Home-only layout with editorial hero
|
||||||
assets/css/main.css Stylesheet (fingerprinted + minified on build)
|
assets/css/main.css Stylesheet (fingerprinted + minified on build)
|
||||||
static/favicon.svg Gate mark in crimson
|
static/favicon.svg Gate mark in crimson
|
||||||
deploy.sh Rsync + remote Hugo build
|
deploy.sh Manual rsync + remote Hugo build (over SSH, for off-CI pushes)
|
||||||
|
deploy-ci.sh Local rsync + Hugo build — runs on forge-runner-01 from CI
|
||||||
```
|
```
|
||||||
|
|
||||||
## Design
|
## Design
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue