From 69c59ad1d52d0eda321a89f8357aeae093e3f5fe Mon Sep 17 00:00:00 2001 From: navigator Date: Wed, 30 Sep 2026 15:45:00 +0000 Subject: [PATCH] feat: redesign the site around composition and the mesh The site now describes the architecture decided on 2026-09-30 (0023 to 0041) and keeps IPv6-first and declarative as its identity. Six pages: the home, architecture, security, cloud and replication, a roadmap mirrored from tracker#46, and the guidelines. Every claim is either true of the code or labelled as decided or a goal, with its decision linked on the tracker. Kept from the previous site: the IPv6 examples, the signed content addressed layers and the build measurements, labelled as measured on the build host, the standards, and an honest status. The core, stack and app diagram is replaced by a composition diagram: Core, Web, runtime and application, with the data services beside them, joined by the mesh. Animations are vanilla JavaScript, self-hosted and loaded only when their element comes into view: a mesh of IPv6 nodes in the hero, the request pipeline, a terminal replay of inspect, diff and apply with output lines from the keel documentation, and scroll reveal. All of them stop under prefers-reduced-motion, and every page works without JavaScript. sitecheck gains four rules, each with tests: the description and social meta tags are required, nothing is loaded from another origin but the analytics script (in pages and in stylesheets), the copy names no commercial offer, customer or city and has no em dash, and a reference to https://keellinux.org/ is checked as a path in the tree. data-src counts as a reference, so a lazily loaded script is not an orphan. --- README.md | 27 +- architecture.html | 802 ++++++++-------------- assets/js/mesh.js | 255 +++++++ assets/js/pipeline.js | 234 +++++++ assets/js/site.js | 194 ++++++ assets/js/terminal.js | 93 +++ cloud.html | 251 +++++++ guidelines.html | 195 ++++-- index.html | 536 ++++++++++----- roadmap.html | 281 ++++++++ security.html | 242 +++++++ style.css | 1389 ++++++++++++++++----------------------- tests/test_sitecheck.py | 203 +++++- tools/sitecheck.py | 185 +++++- 14 files changed, 3245 insertions(+), 1642 deletions(-) create mode 100644 assets/js/mesh.js create mode 100644 assets/js/pipeline.js create mode 100644 assets/js/site.js create mode 100644 assets/js/terminal.js create mode 100644 cloud.html create mode 100644 roadmap.html create mode 100644 security.html diff --git a/README.md b/README.md index e8a7101..1ba169f 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,28 @@ # keellinux.org -The project site, served by GitHub Pages from `main`: three static pages, one -stylesheet and the brand assets. Nothing builds it, so what is in the -repository is what the browser gets. +The project site, served by GitHub Pages from `main`: six static pages, one +stylesheet, four small scripts and the brand assets. Nothing builds it, so +what is in the repository is what the browser gets. | Path | What it is | |------|------------| | `index.html` | the home page, also served at `www` | -| `architecture.html` | the architecture the project aims for, in three scenarios, served at `/architecture` | +| `architecture.html` | composition, overlays, appliances, the manifest, the spec, IPv6-first, layers and upgrades | +| `security.html` | keys and identity, the request pipeline, what each installation mode enables, exposure classes | +| `cloud.html` | installation modes, the mesh, the registry, failover, replication, backup and monitoring | +| `roadmap.html` | a copy of the roadmap, tracker#46, phase by phase | | `guidelines.html` | how the organization works, and the mark | | `style.css` | the whole stylesheet, light and dark | +| `assets/js/site.js` | theme toggle, menu, scroll reveal, and the loader of the modules below | +| `assets/js/mesh.js`, `pipeline.js`, `terminal.js` | the hero's mesh, the request pipeline and the terminal replay; each loads only when its element comes into view | | `assets/` | the mark in its variants and the social card | | `tools/sitecheck.py` | the checks a build step would have done | | `tests/` | one test per rule, ending with the site itself | +Every page works without JavaScript, and the animations stop under +`prefers-reduced-motion`. Nothing is loaded from another origin but the +Plausible analytics script. + ## The check ``` @@ -23,6 +32,10 @@ python3 tools/sitecheck.py . It exits 0 when the site is sound and 1 with one line per finding: an internal link that resolves to nothing, a fragment naming an id that is not there, a referenced asset that is missing, an asset no page references, an -external link that is not https, or a page without a language, a title or -exactly one h1. The gate `python / coverage` runs the same checks on every -pull request and is required on `main`. +external link that is not https, a page without a language, a title, +exactly one h1, a description or the social tags, a resource loaded from +another origin than the analytics one (in a page or in a stylesheet), and +copy that names a commercial offer, a customer or a city, or has an em +dash. A reference to `https://keellinux.org/` is checked as a path in the +tree. The gate `python / coverage` runs the same checks on every pull +request and is required on `main`. diff --git a/architecture.html b/architecture.html index cee07f8..14ec734 100644 --- a/architecture.html +++ b/architecture.html @@ -4,582 +4,314 @@ Architecture, Keel Linux - + + - - + + - - - + + + + + + + + + + + + +
-
+
+

Home / Architecture

Architecture -

How far the architecture goes

-

The architecture the project aims for, in three scenarios. The first is the goal of the first release. The second is decided (decision 0020) and not built. The third is a direction with no design yet.

- -
    -
  1. - Keel Linux Standalone - Our goal · not yet released -
  2. -
  3. - Keel Cloud, three nodes - Planned -
  4. -
  5. - Separated tiers - Future -
  6. -
-
-
- - -
-
- Scenario 1 -
-

Keel Linux Standalone

- Our goal · not yet released -
-

This is what Keel Linux is building first. It runs today from source and test images; there is no release for end users yet.

-

One LXC container or VM runs the whole stack: the application, its database and its files. One instance spec describes it, and Keel keeps the machine and the spec in step.

- -
- - Keel Linux Standalone, the project's goal, not yet released - An operator runs keel inspect, keel diff and keel spec apply against one LXC container or VM at 2001:db8:4b1::10, which holds the application, the database and the files, described by one instance spec. On a VM, a network change opens a 120 second window: keel network confirm from a new session keeps it, otherwise the old network is restored, also after a reboot. In an LXC container the host owns the network and keel only compares it. The network change is marked as VM only, in keel 0.9.0, not yet released. The whole scenario is the project's goal and not yet released. - - - - - - - - OUR GOAL · NOT YET RELEASED - - - - Operator - over SSH or in confconsole - keel inspect - what the machine is - keel diff - spec against machine - keel spec apply - converge, safe to re-run - - - - - - One LXC or VM - 2001:db8:4b1::10 - - application - WordPress, NodeBB, LAMP - - database - MariaDB or PostgreSQL, local - - files - uploads and other state - described by one instance spec - IPv6 first, IPv4 optional - - - - Network change - - VM only · in keel 0.9.0, not yet released - - - apply writes the new network - - a 120 s window opens - - keel network confirm - from a new session: kept - - no confirmation in time: - the old network is restored, - also after a reboot - In LXC the host owns it: compared only - - - - Keel Linux Standalone, the project's goal, not yet released - An operator runs keel inspect, keel diff and keel spec apply against one LXC container or VM at 2001:db8:4b1::10, which holds the application, the database and the files, described by one instance spec. On a VM, a network change opens a 120 second window: keel network confirm from a new session keeps it, otherwise the old network is restored, also after a reboot. In an LXC container the host owns the network and keel only compares it. The network change is marked as VM only, in keel 0.9.0, not yet released. The whole scenario is the project's goal and not yet released. - - - - - - - - OUR GOAL · NOT YET RELEASED - - - Operator - over SSH or in confconsole - keel inspect - what the machine is - keel diff - spec against machine - keel spec apply - converge, safe to re-run - - - - - One LXC or VM - 2001:db8:4b1::10 - - application - WordPress, NodeBB, LAMP - - database - MariaDB or PostgreSQL, local - - files - uploads and other state - described by one instance spec - - - Network change - - VM only · in keel 0.9.0, not yet released - - - apply writes the new network - - a 120 s window opens - - keel network confirm - from a new session: kept - - no confirmation in time: - the old network is restored, - also after a reboot - In LXC the host owns it: compared only - -
Everything runs on one machine. What the spec declares, keel diff compares and keel spec apply converges.
-
- -
    -
  • Declared and compared. keel inspect reads the running machine, keel diff shows where it differs from the spec, keel spec apply converges it and is safe to run again.
  • -
  • Network changes that undo themselves. VM only · in keel 0.9.0, not yet released On a VM, a changed network must be confirmed with keel network confirm from a new session within 120 seconds, or the previous configuration comes back. A machine reached only over SSH is the case this is for. In an LXC container the host owns the network; keel compares it and does not change it.
  • -
  • Not through Webmin. The network is changed in confconsole or at the command line; Webmin shows it read-only (decision 0019).
  • -
+

Composition on a mesh

+

Keel Linux builds appliances from small components instead of shipping one image per stack. Each component is a Debian package, each machine is one YAML file, and the machines find each other over a WireGuard mesh that is IPv6-first.

+

Decided 2026-09-30 spec, inspect, diff, apply working today

- -
-
- Scenario 2 -
-

Keel Cloud, three nodes

- Planned, decision 0020 -
-

Three nodes, LXC containers or VMs, two or three of them holding the application: one primary takes the writes, the replicas follow it and stand ready to take over. Keel Cloud is optional: three standalone appliances configured by hand do the same, and Keel Cloud only automates the setup; without it, the public name is moved by hand after a failover.

- -
- - Keel Cloud with three nodes, planned - Visitors resolve the name through keel-cloud, an optional service with DNS and a health check, node discovery and WireGuard key exchange, which points the name at node A, the primary. Nodes A, B and C sit on a WireGuard private network. Node A at 2001:db8:4b1::11 is the primary and takes the writes. Nodes B and C at ::12 and ::13 are replicas: the database replicates to them and they pull the files from A with rsync. Node C may instead be a voter holding no data. Every node runs etcd and keel-quorum; a majority of the three elects the primary, and a primary that loses the majority turns itself read-only. Without keel-cloud, the same three nodes set up by hand elect and fail over alike; the public name is then moved by hand. - - - - - - - - PLANNED · DECISION 0020 - - - - Visitors - resolve the name - - - - keel-cloud - - optional - DNS with a health check: the name points at the primary - node discovery · WireGuard key exchange - - - Without keel-cloud - the same three nodes, - set up by hand, elect - and fail over alike; - the public name is then - moved by hand - - - DNS answer: node A - - - - WireGuard private network - - - - Node A · primary - 2001:db8:4b1::11 - app · database · files - takes the writes - etcd · keel-quorum - a voter - - - Node B · replica - 2001:db8:4b1::12 - app · database · files - follows, can be promoted - etcd · keel-quorum - a voter - - - Node C · replica - 2001:db8:4b1::13 - app · database · files - follows, can be promoted - etcd · keel-quorum - or only a voter, no data: - etcd and keel-quorum alone - - - database - - files, pulled - - by B, rsync - - - - the same flows reach node C - - - - Election on the nodes: keel-quorum on etcd - A majority of the three voters elects the primary. A primary that loses the majority turns itself read-only (fencing). - Automatic failover needs three voters, to avoid split brain. With fewer, a replica is promoted by hand. - - - - Keel Cloud with three nodes, planned - keel-cloud, an optional service with DNS and a health check, node discovery and WireGuard key exchange, points the name at node A, the primary. Nodes A, B and C sit on a WireGuard private network. Node A at 2001:db8:4b1::11 is the primary and takes the writes. Nodes B and C at ::12 and ::13 are replicas: the database replicates to them and they pull the files from A with rsync. Node C may instead be a voter holding no data. Every node runs etcd and keel-quorum; a majority of the three elects the primary, and a primary that loses the majority turns itself read-only. Without keel-cloud, the same three nodes set up by hand elect and fail over alike; the public name is then moved by hand. - - - - - - - - PLANNED · DECISION 0020 - - - keel-cloud - - optional - DNS with a health check, - the name points at the primary - node discovery · WireGuard keys - Without it, the same three nodes set - up by hand elect and fail over alike; - the public name is then moved by hand. - - - DNS answer: node A - - - WireGuard private network - - - Node A · primary - 2001:db8:4b1::11 - app · database · files - etcd · keel-quorum, a voter - takes the writes - - - database replication - files, pulled by B with rsync - - - Node B · replica - 2001:db8:4b1::12 - app · database · files - etcd · keel-quorum, a voter - - - Node C · replica - 2001:db8:4b1::13 - app · database · files - etcd · keel-quorum, a voter - or only a voter: no data, not an appliance - - - - - Election on the nodes - keel-quorum on etcd. A majority of - the three voters elects the primary. - A primary that loses the majority - turns itself read-only (fencing). - Automatic failover needs three voters. - With fewer, a replica is promoted - by hand. - -
One writer at a time. The replicas pull, the primary never pushes, and the election runs on the nodes themselves, so no outside service is required for a failover; without Keel Cloud, the public name is then moved by hand.
-
- -
    -
  • Keel Cloud is optional. keel-cloud adds DNS with a health check, node discovery and WireGuard key exchange. It is in the spirit of TurnKey's HubDNS, which updates a name but checks no health. An API key for it is offered at installation and never required.
  • -
  • The election is its own project, keel-quorum. One etcd election for the whole appliance: database, files and web tier are promoted together. Every data node is a voter; the third node may be a voter that holds no data, which is not an appliance and runs only etcd and keel-quorum.
  • -
  • Private network. The nodes talk over WireGuard. Its configuration is converged under the same 120 second confirmation window as any network change.
  • -
  • Status panel. A read-only screen in confconsole shows one line per node: role, replication lag, last file pull, health, and the elected leader. It changes nothing on the other nodes.
  • -
  • What it does not promise. Replication and the file pull are asynchronous, so the last seconds before a failure can be lost. DNS takes one to five minutes to follow a failover, and some resolvers ignore low TTLs. In an LXC container there is no watchdog, so fencing relies on the process demoting itself in time; a VM can have one.
  • -
- -

Chosen by the operator when the set is formed

-

Made once and stored in etcd, so every node reads the same answer. The same choices exist with or without Keel Cloud.

-
- - - - - - - - - -
ChoiceOptionsDefault
Failover: a replica takes over when the primary is lostautomatic, manualautomatic, active only once three voters are present
Rejoin: the old primary comes back as a replicaautomatic, manualmanual. Automatic first keeps a copy of what the node held
Failback: the primary role returns to the original nodeautomatic, manual, nevermanual
-
-

When: after the orchestrated upgrade of milestone M3. Keel Cloud comes last and starts with a decision note of its own, so its design beyond the three services above is not written yet.

-
-
- - -
-
- Scenario 3 -
-

Separated tiers

- Future -
-

The application and the database move to machines of their own. Not designed yet: only the remote database (0013 phase 1) and Galera (0013 phase 4) are planned pieces.

- -
- - Separated tiers, future, not designed yet - An application tier of three VMs, app 1 to app 3 at 2001:db8:4b1::21 to ::23; how they coordinate is to be designed. The application points at the remote database, decision 0013 phase 1. A database tier of three VMs, db 1 to db 3 at 2001:db8:4b1::31 to ::33, with primary and replicas, or Galera members for MariaDB. Sharding is possible: MariaDB through Spider; for PostgreSQL Keel configures the node's role and the shard design belongs to whoever designs the system. - - - - - - - - FUTURE - - Application tier - three VMs; how they coordinate is to be designed - - - app 1 - 2001:db8:4b1::21 - application - - app 2 - 2001:db8:4b1::22 - application - - app 3 - 2001:db8:4b1::23 - application - - - the application points at the remote database - decision 0013, phase 1 - - Database tier - three VMs: primary and replicas, or Galera for MariaDB - - - db 1 - 2001:db8:4b1::31 - primary, or a Galera member - - db 2 - 2001:db8:4b1::32 - replica, or a Galera member - - db 3 - 2001:db8:4b1::33 - replica, or a Galera member - - - Sharding, possible - MariaDB: the Spider engine, a head node and data nodes. - PostgreSQL: Keel configures the node's role; the shard design belongs to whoever designs the system. - - - - Separated tiers, future, not designed yet - An application tier of three VMs, app 1 to app 3 at 2001:db8:4b1::21 to ::23; how they coordinate is to be designed. The application points at the remote database, decision 0013 phase 1. A database tier of three VMs, db 1 to db 3 at 2001:db8:4b1::31 to ::33, with primary and replicas, or Galera members for MariaDB. Sharding is possible: MariaDB through Spider; for PostgreSQL Keel configures the node's role and the shard design belongs to whoever designs the system. - - - - - - - - FUTURE - - Application tier - three VMs, coordination to be designed - - - app 1 - ::21 - application - - app 2 - ::22 - application - - app 3 - ::23 - application - in 2001:db8:4b1::/64 - - - points at the remote database - decision 0013, phase 1 - - Database tier - three VMs - - - db 1 - ::31 - primary - - db 2 - ::32 - replica - - db 3 - ::33 - replica - or three Galera members, for MariaDB - - - Sharding, possible - MariaDB: the Spider engine, - a head node and data nodes. - PostgreSQL: Keel configures the - node's role; the shard design - belongs to whoever designs - the system. - -
Each tier on machines of its own. Keel configures the role of each machine; it does not design the database layout.
-
+
+ -
    -
  • A remote database first. An application pointing at a database on another machine is the first piece (decision 0013, phase 1). It is deferred today.
  • -
  • Replication, or Galera. Primary and replicas on both engines; Galera gives MariaDB several writers, with a minimum of three nodes.
  • -
  • Sharding is possible, not promised. MariaDB has Spider packaged in Debian 13. PostgreSQL has the pieces but no product: Keel configures the role of the node, and the partition scheme stays with whoever designs the system.
  • -
+
+
+

Composition

+

Each service, PostgreSQL, MariaDB, Redis or Nginx, is a first-class component that runs standalone or is consumed by another appliance. The stack that used to be LAMP becomes LEMP (Linux, Nginx, PHP-FPM and a database), and LEMP is a recipe built from components, not an image with an identity of its own.

+
+ The rule +

What two or more images use is an overlay, kept in common with its configuration and first boot scripts. A named combination with an identity is an appliance.

+
+
+
+

An appliance, top to bottom

+
    +
  • Applicationa manifest and an application inithookPhases 4 and 6
  • +
  • RuntimeKeel PHP, Python, Ruby or Node: Keel Web and the runtimePhase 4
  • +
  • Keel WebCore, nginx, coraza, anubisPhase 3
  • +
  • Keel CoreDebian, wireguard, etcd, crowdsec, the installerPhase 1
  • +
+
+ +
+

Data services, beside it

+
    +
  • Keel PostgreSQL, Keel MariaDBCore, the database, vip, syncthing
  • +
  • Keel RedisCore, redis
  • +
  • SearchElasticsearch or OpenSearch, not yet chosen
  • +
  • Object storageGarage, as an s3 overlay
  • +
+
+
+

Embedded in a simple installation; discovered on the mesh in an advanced one. A search index is derived data: it is rebuilt when a node joins and never replicated by file. Decisions 0023, 0033 and 0036.

+
+ +
+

Overlays

+

An overlay is one component: its packages, its configuration and its first boot scripts. Under decision 0039 each overlay is its own Debian source package, keel-overlay-<name>, released on its own with its own changelog.

+
+ + + + + + + + + + +
GroupOverlaysDefault
Meshwireguard, etcd, vip, syncthingetcd stopped
Protectioncrowdsec, coraza, anubisdisabled
Webnginx
Runtimesphp-fpm, python, ruby, nodejs, go
Datapostgresql, mariadb, redis, a search engine
Installersimple and advanced installation, cloud simple and cloud advanced, discovery, YAML emission and consumption
+
+

Apache is not an overlay: it is retired as the default web server and migrated appliance by appliance. The WordPress test images built today still use it, until Keel PHP exists.

+
+ +
+

Appliances

+
+ + + + + + + + + + + +
ApplianceComposition
Keel CoreDebian, wireguard, etcd, crowdsec, the installer
Keel WebCore, nginx, coraza, anubis
Keel PostgreSQL, Keel MariaDBCore, the database, vip, syncthing
Keel RedisCore, redis
Keel searchCore, the search engine
Keel PHPWeb, php-fpm; replaces LAMP and LAPP
Keel Python, Keel Ruby, Keel NodeWeb and the runtime
+
+
+ +
+

Applications

+

An application appliance declares its runtime, the data services it consumes, the paths of its durable state and its workers. Workers are stateless: they read a queue and write to data services, never to local disk. Planned, Phases 4 and 6

+
+ + + + + + + + + + + +
ApplicationConsumesReplicated state, workers
WordPressPHP, MariaDBwp-content
NextcloudPHP, MariaDB, Redis, optionally searchdata
OdooPython, PostgreSQL, optionally Redisfilestore and addons; cron and queue workers
MastodonRuby, PostgreSQL, Redis, optionally searchmedia; Sidekiq
GhostNode, MariaDB
DiscourseRuby, PostgreSQL, RedisSidekiq
Gitea or ForgejoWeb, go, PostgreSQLrepositories
+
+

Mastodon is the reference appliance: Ruby, Node.js, PostgreSQL, Redis, Sidekiq and usually object storage and search make it the heaviest of the set, so it is the proof of the composition model. It is built last in its phase, after WordPress, Odoo and Nextcloud (0035).

+
+ +
+

The manifest

+

Backup, monitoring, upgrades, directory replication and discovery all read the same facts: which paths hold data, which processes to watch, what a node offers. So the manifest format came first. Version 1 is decided, and its reader is the first step of the implementation (tracker#39). Decided, 0041

+
    +
  • Two kinds, one schema. An overlay manifest ships in each overlay's package and says what it runs. An appliance manifest ships in each appliance's package and says what it is built on and which overlays it adds.
  • +
  • The manifest owns facts, the spec owns choices. A manifest never names a host, an address, a secret file or a count.
  • +
  • A state per installation mode. Every overlay is enabled, disabled or ask in each of the three modes. The image is the same; the mode decides what runs.
  • +
  • Inheritance is additive. Keel Web in a simple installation is one thing for every appliance built on it.
  • +
  • Derived, never declared. Monit's checks, the firewall, the backup set, the Syncthing folders and the registry entry are rendered from the manifests.
  • +
  • Versioned by an integer, with unknown keys refused at every level.
  • +
+
# /usr/share/keel/appliances/web.yaml, from keel-web
+manifest_version: 1
+kind: appliance
+name: web
+title: Keel Web
+summary: Nginx, Coraza and Anubis, the base of every web appliance
+base: core
+overlays:
+  nginx:  {simple: enabled,  cloud_simple: enabled, cloud_advanced: enabled}
+  coraza: {simple: disabled, cloud_simple: enabled, cloud_advanced: enabled}
+  anubis: {simple: disabled, cloud_simple: enabled, cloud_advanced: enabled}
+

From the version 1 format. Keel Web has no process of its own; its manifest is three lines of states over Core's.

+
+ +
+

The spec: one file is the truth

+

The instance spec, /etc/keel/instance.yaml, declares one machine: its names, IPv6 network, certificate policy, users, secrets by reference, database role and monitoring. It is rendered into the variables TurnKey's first boot hooks already read, so no hook has to know that it exists, and the keel command and the console are both clients of the same library. Working today

+
+ + + + + + + + + +
CommandWhat it does
keel inspectWrite a spec from the running machine or an offline root, every field with its source or the reason it was not inferred. Secrets are never read.
keel diffReport drift between the spec and the machine, field by field, through the same collector.
keel spec applyConverge what the spec declares, only where it differs. Safe to run again.
keel spec validateReport every error in the spec, not just the first.
keel network confirmKeep a network change; without it, the change reverts by itself.
+
+
version: 1
+instance:
+  hostname: blog
+  fqdn: blog.example.org
+network:
+  interfaces:
+    eth0:
+      ipv6:
+        method: static
+        address: 2001:db8:1::10/64
+        gateway: fe80::1
+  nameservers:
+    - 2001:db8:1::53
+secrets:
+  root_password:
+    file: /etc/keel/secrets/root_password
+

Decided next (0027, 0041): every installation, simple or advanced, by hand or automated, emits its complete YAML with every default written out, and re-emits it after every upgrade. The spec gains the appliance, the installation mode and the state of every overlay.

+
+ +
+

IPv6-first

+

Every appliance is reachable on a routable address of its own, such as 2001:db8:4b1::10, with an ACME certificate and its own host keys. No port mapping. IPv4 is optional and never assumed, and the project's examples and defaults use IPv6.

+
    +
  • Between nodes, native IPv6 goes direct. An IPv4-only node enters the mesh through a rendezvous point that translates (NAT64 with Tayga, in Debian 13).
  • +
  • On the mesh, keel network wireguard suggest-address prints a random unique local IPv6 address with its /64 for the first node of an overlay.
  • +
  • In the spec, nameservers are listed IPv6 first, and a database listens on literal addresses such as ::1, because on Debian localhost is an IPv4-only name.
  • +
+
+ +
+

Signed, content-addressed layers

+

An appliance is assembled from read-only layers, one per appliance in the chain. Each layer is a deterministic tarball with a sha256, listed in a plain-text layer manifest that is signed by the project key and tied to a git commit. The same commit and manifest give the same bytes, and an update downloads only the layer that changed.

+
    +
  • keel pull fetches the layers the cache lacks, from a directory or an https URL, checking every digest.
  • +
  • keel assemble extracts a cached chain into a root filesystem and packs it as a Proxmox template with its sha512.
  • +
  • keel verify checks installed layers against their manifests.
  • +
  • Layers are published on two channels, stable and testing; a third channel would need a decision of its own.
  • +
+

Build measurements, on the build host with unmodified TurnKey 19.0 recipes: core layer 326 MB, LAMP stack delta 80 MB, WordPress app delta 133 MB, WordPress rebuilt on cached layers in 69 s instead of 451 s. On the home page.

+
+ +
+

Upgrades come from the Keel repository

+

Decided, 0039

+
    +
  • Everything Keel installs is a .deb in the Keel repository, overlays included, and apt upgrade is the only update mechanism.
  • +
  • Application packages carry a post-install migration hook, such as Odoo's module update or Mastodon's db:migrate.
  • +
  • Upgrades are ordered across the mesh: a database primary hands over to its standby, upgrades, and takes the role back after catching up.
  • +
  • Two tracks, stable and testing, declared in the YAML. A simple installation defaults to stable, with unattended upgrades for security only.
  • +
  • The archive is built from a dated, pinned pool, so every image rebuilds from the project's own archive.
  • +
+
-
+
- - diff --git a/assets/js/mesh.js b/assets/js/mesh.js new file mode 100644 index 0000000..35978a6 --- /dev/null +++ b/assets/js/mesh.js @@ -0,0 +1,255 @@ +/* The hero's mesh: appliances joining a WireGuard mesh over IPv6. + Tunnels come up one by one, packets travel them, and one node behind a + NAT opens its tunnel from the inside. Loaded lazily by site.js. The + addresses are from the documentation prefix, 2001:db8::/32. */ +(function () { + "use strict"; + + var NODES = [ + { x: 0.16, y: 0.20, label: "2001:db8:a::10" }, + { x: 0.50, y: 0.12, label: "2001:db8:b::10" }, + { x: 0.85, y: 0.24, label: "2001:db8:c::10" }, + { x: 0.30, y: 0.52, label: "2001:db8:a::20" }, + { x: 0.68, y: 0.50, label: "2001:db8:b::20" }, + { x: 0.12, y: 0.84, label: "behind NAT", nat: true }, + { x: 0.47, y: 0.84, label: "2001:db8:c::20" }, + { x: 0.86, y: 0.78, label: "2001:db8:d::10" }, + { x: 0.62, y: 0.28, small: true }, + { x: 0.36, y: 0.30, small: true }, + { x: 0.75, y: 0.66, small: true }, + { x: 0.24, y: 0.68, small: true } + ]; + // Tunnels, in the order they come up. The last one is the NAT's. + var EDGES = [ + [0, 9], [9, 1], [1, 8], [8, 2], [0, 3], [9, 3], [8, 4], [2, 4], + [3, 4], [3, 11], [11, 6], [4, 10], [10, 7], [6, 7], [1, 4], [2, 7], + [5, 11, "nat"] + ]; + + function colors() { + var style = getComputedStyle(document.documentElement); + function read(name, fallback) { + var value = style.getPropertyValue(name).trim(); + return value || fallback; + } + return { + mesh: read("--mesh", "#0E7C77"), + accent: read("--accent", "#0072C9"), + fg: read("--fg", "#0B1F3A"), + faint: read("--faint", "#5F7089"), + surface: read("--surface", "#FFFFFF"), + line: read("--line-strong", "#B9C7D6") + }; + } + + function mesh(element, options) { + var canvas = element.querySelector("canvas"); + if (!canvas || !canvas.getContext) { return; } + var context = canvas.getContext("2d"); + var palette = colors(); + var width = 0; + var height = 0; + var started = null; // set when the mesh is first seen, so its tunnels come up in view + var packets = []; + var lastPacket = 0; + var running = false; + var visible = true; + var frame = 0; + var still = options.reduceMotion; + var counter = element.querySelector("[data-mesh-count]"); + + function resize() { + var ratio = Math.min(window.devicePixelRatio || 1, 2); + var box = canvas.getBoundingClientRect(); + width = box.width; + height = box.height; + canvas.width = Math.round(width * ratio); + canvas.height = Math.round(height * ratio); + context.setTransform(ratio, 0, 0, ratio, 0, 0); + if (!running) { draw(settled()); } + } + + // A frame with every tunnel up: for reduced motion, and before the mesh is seen + function settled() { + return still || started === null ? 1e6 : performance.now() - started; + } + + function position(node, time) { + var drift = still ? 0 : 1; + var phase = node.x * 13 + node.y * 7; + return { + x: (node.x + drift * 0.012 * Math.sin(time / 2600 + phase)) * width, + y: (node.y + drift * 0.014 * Math.cos(time / 3100 + phase)) * height + }; + } + + function edgeProgress(index, time) { + if (still) { return 1; } + var begin = 300 + index * 220; + return Math.max(0, Math.min(1, (time - begin) / 520)); + } + + function drawEdge(edge, index, time, points) { + var progress = edgeProgress(index, time); + if (progress <= 0) { return; } + var a = points[edge[0]]; + var b = points[edge[1]]; + var nat = edge[2] === "nat"; + var x = a.x + (b.x - a.x) * progress; + var y = a.y + (b.y - a.y) * progress; + context.save(); + context.strokeStyle = nat ? palette.accent : palette.mesh; + context.globalAlpha = nat ? 0.9 : 0.55; + context.lineWidth = nat ? 1.6 : 1.3; + if (nat) { + context.setLineDash([6, 5]); + context.lineDashOffset = still ? 0 : -time / 40; + } + context.beginPath(); + context.moveTo(a.x, a.y); + context.lineTo(x, y); + context.stroke(); + context.restore(); + } + + function drawNode(node, point, index, time) { + var joined = EDGES.some(function (edge, e) { + return (edge[0] === index || edge[1] === index) && edgeProgress(e, time) >= 1; + }); + var radius = node.small ? 3.5 : 6; + context.save(); + if (!node.small && joined && !still) { + var pulse = (time / 1800 + index * 0.37) % 1; + context.globalAlpha = 0.35 * (1 - pulse); + context.fillStyle = node.nat ? palette.accent : palette.mesh; + context.beginPath(); + context.arc(point.x, point.y, radius + 14 * pulse, 0, Math.PI * 2); + context.fill(); + } + context.globalAlpha = 1; + context.fillStyle = palette.surface; + context.strokeStyle = joined ? (node.nat ? palette.accent : palette.mesh) : palette.line; + context.lineWidth = 2; + context.beginPath(); + context.arc(point.x, point.y, radius, 0, Math.PI * 2); + context.fill(); + context.stroke(); + if (!node.small) { + context.fillStyle = joined ? (node.nat ? palette.accent : palette.mesh) : palette.line; + context.beginPath(); + context.arc(point.x, point.y, 2.5, 0, Math.PI * 2); + context.fill(); + } + if (node.label && width > 300) { + context.font = "600 " + (width < 460 ? 10 : 11) + "px ui-monospace, Menlo, Consolas, monospace"; + context.textAlign = node.x > 0.75 ? "right" : node.x < 0.2 ? "left" : "center"; + var dx = node.x > 0.75 ? 8 : node.x < 0.2 ? -8 : 0; + var labelX = point.x + dx; + var labelY = point.y + radius + 16; + var textWidth = context.measureText(node.label).width; + var left = context.textAlign === "right" ? labelX - textWidth : + context.textAlign === "left" ? labelX : labelX - textWidth / 2; + context.globalAlpha = 0.9; + context.fillStyle = palette.surface; + context.fillRect(left - 4, labelY - 11, textWidth + 8, 15); + context.globalAlpha = 1; + context.fillStyle = palette.faint; + context.fillText(node.label, labelX, labelY); + } + context.restore(); + } + + function spawn(time) { + var ready = EDGES.filter(function (edge, index) { return edgeProgress(index, time) >= 1; }); + if (!ready.length) { return; } + var edge = ready[Math.floor(Math.random() * ready.length)]; + var forward = edge[2] === "nat" ? true : Math.random() > 0.5; + packets.push({ + from: forward ? edge[0] : edge[1], + to: forward ? edge[1] : edge[0], + born: time, + life: 1100 + Math.random() * 900, + nat: edge[2] === "nat" + }); + } + + function drawPackets(time, points) { + packets = packets.filter(function (packet) { return time - packet.born < packet.life; }); + packets.forEach(function (packet) { + var t = (time - packet.born) / packet.life; + var eased = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; + var a = points[packet.from]; + var b = points[packet.to]; + var x = a.x + (b.x - a.x) * eased; + var y = a.y + (b.y - a.y) * eased; + context.save(); + context.fillStyle = packet.nat ? palette.accent : palette.mesh; + context.shadowColor = context.fillStyle; + context.shadowBlur = 10; + context.beginPath(); + context.arc(x, y, 2.8, 0, Math.PI * 2); + context.fill(); + context.restore(); + }); + } + + function draw(time) { + context.clearRect(0, 0, width, height); + var points = NODES.map(function (node) { return position(node, time); }); + EDGES.forEach(function (edge, index) { drawEdge(edge, index, time, points); }); + if (!still) { drawPackets(time, points); } + NODES.forEach(function (node, index) { drawNode(node, points[index], index, time); }); + if (counter) { + var up = EDGES.filter(function (edge, index) { return edgeProgress(index, time) >= 1; }).length; + counter.textContent = up + "/" + EDGES.length + " tunnels up"; + } + } + + function tick(now) { + if (!running) { return; } + var time = now - started; + if (time - lastPacket > 260) { + spawn(time); + lastPacket = time; + } + draw(time); + frame = window.requestAnimationFrame(tick); + } + + function play() { + if (still || running || !visible || document.hidden) { return; } + if (started === null) { started = performance.now(); } + running = true; + frame = window.requestAnimationFrame(tick); + } + + function pause() { + running = false; + window.cancelAnimationFrame(frame); + } + + resize(); + if (window.ResizeObserver) { + new ResizeObserver(resize).observe(canvas); + } else { + window.addEventListener("resize", resize); + } + document.addEventListener("keel:theme", function () { + palette = colors(); + if (!running) { draw(settled()); } + }); + if ("IntersectionObserver" in window) { + new IntersectionObserver(function (entries) { + visible = entries[0].isIntersecting; + if (visible) { play(); } else { pause(); } + }).observe(canvas); + } + document.addEventListener("visibilitychange", function () { + if (document.hidden) { pause(); } else { play(); } + }); + if (still) { draw(1e6); } else { play(); } + } + + window.KeelModules = window.KeelModules || {}; + window.KeelModules.mesh = mesh; +})(); diff --git a/assets/js/pipeline.js b/assets/js/pipeline.js new file mode 100644 index 0000000..f76b1c9 --- /dev/null +++ b/assets/js/pipeline.js @@ -0,0 +1,234 @@ +/* The request pipeline of Keel Web in an advanced installation (decision + 0030): CrowdSec bans first, Nginx terminates TLS and runs the WAF inline, + Anubis sits behind Nginx, then the application. Requests are drawn as + dots; the list under the canvas says the same in words. Loaded lazily. */ +(function () { + "use strict"; + + var STATIONS = [ + { name: "Internet", short: "Net" }, + { name: "CrowdSec", short: "CrowdSec" }, + { name: "Nginx + WAF", short: "WAF" }, + { name: "Anubis", short: "Anubis" }, + { name: "Application", short: "App" } + ]; + // Where a request ends: banned by CrowdSec, refused by the WAF, or it + // reaches the application, after an Anubis challenge unless it is an + // exempt crawler. The shares are for the picture, not a measurement. + var OUTCOMES = [ + { stop: 1, kind: "ban", weight: 0.16 }, + { stop: 2, kind: "waf", weight: 0.14 }, + { stop: 4, kind: "pow", weight: 0.42 }, + { stop: 4, kind: "crawler", weight: 0.28 } + ]; + + function colors() { + var style = getComputedStyle(document.documentElement); + function read(name, fallback) { + var value = style.getPropertyValue(name).trim(); + return value || fallback; + } + return { + mesh: read("--mesh", "#0E7C77"), + accent: read("--accent", "#0072C9"), + warn: read("--warn", "#8A5300"), + stop: read("--stop", "#B42318"), + fg: read("--fg", "#0B1F3A"), + muted: read("--muted", "#44576F"), + surface: read("--surface-2", "#F2F6FA"), + line: read("--line-strong", "#B9C7D6") + }; + } + + function pick() { + var roll = Math.random(); + for (var i = 0; i < OUTCOMES.length; i += 1) { + roll -= OUTCOMES[i].weight; + if (roll <= 0) { return OUTCOMES[i]; } + } + return OUTCOMES[OUTCOMES.length - 1]; + } + + function pipeline(element, options) { + var canvas = element.querySelector("canvas"); + if (!canvas || !canvas.getContext) { return; } + var context = canvas.getContext("2d"); + var palette = colors(); + var width = 0; + var height = 0; + var requests = []; + var last = 0; + var running = false; + var visible = false; + var frame = 0; + var still = options.reduceMotion; + + function stationX(index) { + // The centre of each column of the list under the canvas + return (width * (2 * index + 1)) / (2 * STATIONS.length); + } + + function resize() { + var ratio = Math.min(window.devicePixelRatio || 1, 2); + var box = canvas.getBoundingClientRect(); + width = box.width; + height = box.height; + canvas.width = Math.round(width * ratio); + canvas.height = Math.round(height * ratio); + context.setTransform(ratio, 0, 0, ratio, 0, 0); + if (!running) { draw(performance.now()); } + } + + function drawStations() { + var mid = height * 0.46; + context.save(); + context.strokeStyle = palette.line; + context.lineWidth = 2; + context.beginPath(); + context.moveTo(stationX(0), mid); + context.lineTo(stationX(STATIONS.length - 1), mid); + context.stroke(); + STATIONS.forEach(function (station, index) { + var x = stationX(index); + var size = index === 0 ? 14 : 20; + context.fillStyle = palette.surface; + context.strokeStyle = index === STATIONS.length - 1 ? palette.mesh : palette.fg; + context.lineWidth = 2; + context.beginPath(); + if (context.roundRect) { + context.roundRect(x - size, mid - size, size * 2, size * 2, 8); + } else { + context.rect(x - size, mid - size, size * 2, size * 2); + } + context.fill(); + context.stroke(); + context.fillStyle = palette.muted; + context.font = "600 " + (width < 520 ? 10 : 12) + "px system-ui, sans-serif"; + context.textAlign = "center"; + context.fillText(width < 520 ? station.short : station.name, x, mid + 42); + }); + context.restore(); + } + + function spawn(time) { + var outcome = pick(); + requests.push({ born: time, outcome: outcome, lane: (Math.random() - 0.5) * 10 }); + } + + function colorOf(kind) { + if (kind === "ban") { return palette.stop; } + if (kind === "waf") { return palette.warn; } + if (kind === "pow") { return palette.accent; } + return palette.mesh; + } + + function drawRequest(request, time) { + var speed = width / 3200; // pixels per millisecond + var start = stationX(0); + var end = stationX(request.outcome.stop); + var challenge = request.outcome.kind === "pow"; + var anubis = stationX(3); + var hold = challenge ? 650 : 0; + var age = time - request.born; + var x = start + age * speed; + var fade = 1; + var held = false; + if (challenge && x >= anubis) { + var over = age - (anubis - start) / speed; + if (over < hold) { x = anubis; held = true; } else { x = anubis + (over - hold) * speed; } + } + if (x >= end) { + var after = age - (end - start) / speed - hold; + x = end; + fade = Math.max(0, 1 - after / 500); + if (fade <= 0) { request.dead = true; return; } + } + var y = height * 0.46 + request.lane; + context.save(); + context.globalAlpha = fade; + context.fillStyle = colorOf(request.outcome.kind); + if (request.outcome.kind === "crawler") { + context.fillRect(x - 3.5, y - 3.5, 7, 7); + } else { + context.beginPath(); + context.arc(x, y, 3.8, 0, Math.PI * 2); + context.fill(); + } + if (held) { + context.strokeStyle = palette.accent; + context.lineWidth = 1.5; + context.beginPath(); + context.arc(x, y, 9, -Math.PI / 2, -Math.PI / 2 + ((time / 180) % (Math.PI * 2))); + context.stroke(); + } + if (x === end && request.outcome.stop < 4) { + context.fillStyle = colorOf(request.outcome.kind); + context.font = "700 11px ui-monospace, Menlo, Consolas, monospace"; + context.textAlign = "center"; + context.fillText(request.outcome.kind === "ban" ? "banned" : "403", x, height * 0.46 - 28 - (1 - fade) * 10); + } + context.restore(); + } + + function drawStill() { + var mid = height * 0.46; + var samples = [ + { x: 0.5, kind: "ban", stop: 1 }, { x: 1.5, kind: "waf" }, { x: 2.5, kind: "pow" }, + { x: 3.5, kind: "crawler" }, { x: 3.7, kind: "pow" } + ]; + samples.forEach(function (sample) { + var from = Math.floor(sample.x); + var x = stationX(from) + (stationX(from + 1) - stationX(from)) * (sample.x - from); + context.fillStyle = colorOf(sample.kind); + context.beginPath(); + context.arc(x, mid, 3.8, 0, Math.PI * 2); + context.fill(); + }); + } + + function draw(time) { + context.clearRect(0, 0, width, height); + drawStations(); + if (still) { drawStill(); return; } + requests.forEach(function (request) { drawRequest(request, time); }); + requests = requests.filter(function (request) { return !request.dead; }); + } + + function tick(now) { + if (!running) { return; } + if (now - last > 420) { spawn(now); last = now; } + draw(now); + frame = window.requestAnimationFrame(tick); + } + + function play() { + if (still || running || !visible || document.hidden) { return; } + running = true; + frame = window.requestAnimationFrame(tick); + } + + function pause() { + running = false; + window.cancelAnimationFrame(frame); + } + + resize(); + if (window.ResizeObserver) { new ResizeObserver(resize).observe(canvas); } + document.addEventListener("keel:theme", function () { palette = colors(); if (!running) { draw(performance.now()); } }); + if ("IntersectionObserver" in window) { + new IntersectionObserver(function (entries) { + visible = entries[0].isIntersecting; + if (visible) { play(); } else { pause(); } + }).observe(canvas); + } else { + visible = true; + play(); + } + document.addEventListener("visibilitychange", function () { + if (document.hidden) { pause(); } else { play(); } + }); + } + + window.KeelModules = window.KeelModules || {}; + window.KeelModules.pipeline = pipeline; +})(); diff --git a/assets/js/site.js b/assets/js/site.js new file mode 100644 index 0000000..1ceceba --- /dev/null +++ b/assets/js/site.js @@ -0,0 +1,194 @@ +/* Keel Linux site: theme, navigation, scroll reveal and lazy modules. + Vanilla JavaScript, no dependencies. Every page works without it. */ +(function () { + "use strict"; + + var root = document.documentElement; + var reduceMotion = window.matchMedia && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + window.KeelModules = window.KeelModules || {}; + window.keelReady = true; + + /* Theme toggle. The stored choice is read before paint by the inline + script in each page's head; this only switches and remembers it. */ + function initTheme() { + var button = document.getElementById("theme-toggle"); + if (!button || !window.matchMedia) { return; } + var query = window.matchMedia("(prefers-color-scheme: dark)"); + + function current() { + var set = root.getAttribute("data-theme"); + if (set === "light" || set === "dark") { return set; } + return query.matches ? "dark" : "light"; + } + + function describe() { + var next = current() === "dark" ? "light" : "dark"; + button.setAttribute("aria-label", "Switch to " + next + " theme"); + button.title = "Switch to " + next + " theme"; + } + + button.addEventListener("click", function () { + var next = current() === "dark" ? "light" : "dark"; + root.setAttribute("data-theme", next); + try { localStorage.setItem("keel-theme", next); } catch (e) { /* private mode */ } + describe(); + document.dispatchEvent(new CustomEvent("keel:theme")); + }); + + if (query.addEventListener) { + query.addEventListener("change", function () { + describe(); + document.dispatchEvent(new CustomEvent("keel:theme")); + }); + } + describe(); + button.hidden = false; + } + + /* The menu button on narrow screens */ + function initNav() { + var button = document.getElementById("nav-toggle"); + var nav = document.getElementById("site-nav"); + if (!button || !nav) { return; } + + function set(open) { + nav.classList.toggle("is-open", open); + button.setAttribute("aria-expanded", open ? "true" : "false"); + button.setAttribute("aria-label", open ? "Close menu" : "Open menu"); + } + + button.addEventListener("click", function () { + set(button.getAttribute("aria-expanded") !== "true"); + }); + document.addEventListener("keydown", function (event) { + if (event.key === "Escape" && nav.classList.contains("is-open")) { + set(false); + button.focus(); + } + }); + nav.addEventListener("click", function (event) { + if (event.target.closest("a")) { set(false); } + }); + button.hidden = false; + } + + /* Scroll reveal: elements marked data-reveal fade in once */ + function initReveal() { + var items = document.querySelectorAll("[data-reveal]"); + if (!root.classList.contains("js-motion") || !("IntersectionObserver" in window)) { + items.forEach(function (item) { item.classList.add("is-in"); }); + return; + } + var observer = new IntersectionObserver(function (entries) { + entries.forEach(function (entry) { + if (entry.isIntersecting) { + entry.target.classList.add("is-in"); + observer.unobserve(entry.target); + } + }); + }, { rootMargin: "0px 0px -8% 0px", threshold: 0.08 }); + items.forEach(function (item) { observer.observe(item); }); + } + + /* Lazy modules: an element with data-module and data-src loads its script + only when it comes near the viewport, then the module starts on it. */ + var loaded = {}; + + function start(name, element) { + var module = window.KeelModules[name]; + if (typeof module !== "function" || element.dataset.started) { return; } + element.dataset.started = "1"; + try { + module(element, { reduceMotion: reduceMotion }); + } catch (error) { + if (window.console) { window.console.error("keel module " + name, error); } + } + } + + function load(element) { + var name = element.getAttribute("data-module"); + var src = element.getAttribute("data-src"); + if (!name || !src) { return; } + if (loaded[src] === true) { start(name, element); return; } + if (loaded[src]) { loaded[src].push(element); return; } + loaded[src] = [element]; + var script = document.createElement("script"); + script.src = src; + script.async = true; + script.onload = function () { + var waiting = loaded[src]; + loaded[src] = true; + waiting.forEach(function (item) { start(name, item); }); + }; + script.onerror = function () { + if (window.console) { window.console.error("keel: could not load " + src); } + }; + document.head.appendChild(script); + } + + function initModules() { + var elements = document.querySelectorAll("[data-module][data-src]"); + if (!("IntersectionObserver" in window)) { + elements.forEach(load); + return; + } + var observer = new IntersectionObserver(function (entries) { + entries.forEach(function (entry) { + if (entry.isIntersecting) { + observer.unobserve(entry.target); + load(entry.target); + } + }); + }, { rootMargin: "300px 0px" }); + elements.forEach(function (element) { observer.observe(element); }); + } + + /* Contents column: mark the section being read */ + function initToc() { + var links = document.querySelectorAll(".toc a[href^='#']"); + if (!links.length || !("IntersectionObserver" in window)) { return; } + var byId = {}; + links.forEach(function (link) { byId[link.getAttribute("href").slice(1)] = link; }); + var observer = new IntersectionObserver(function (entries) { + entries.forEach(function (entry) { + if (!entry.isIntersecting) { return; } + links.forEach(function (link) { link.classList.remove("is-active"); }); + var link = byId[entry.target.id]; + if (link) { link.classList.add("is-active"); } + }); + }, { rootMargin: "-20% 0px -70% 0px" }); + Object.keys(byId).forEach(function (id) { + var section = document.getElementById(id); + if (section) { observer.observe(section); } + }); + } + + /* Roadmap: show every task, only the open ones, or only the done ones */ + function initFilters() { + var group = document.querySelector("[data-filters]"); + if (!group) { return; } + var buttons = group.querySelectorAll("button"); + var lists = document.querySelectorAll(".tasks"); + buttons.forEach(function (button) { + button.addEventListener("click", function () { + var mode = button.getAttribute("data-filter"); + buttons.forEach(function (other) { + other.setAttribute("aria-pressed", other === button ? "true" : "false"); + }); + lists.forEach(function (list) { + list.classList.toggle("only-open", mode === "open"); + list.classList.toggle("only-done", mode === "done"); + }); + }); + }); + group.hidden = false; + } + + initTheme(); + initNav(); + initReveal(); + initModules(); + initToc(); + initFilters(); +})(); diff --git a/assets/js/terminal.js b/assets/js/terminal.js new file mode 100644 index 0000000..dfcca1a --- /dev/null +++ b/assets/js/terminal.js @@ -0,0 +1,93 @@ +/* A terminal that replays a keel session: the transcript is in the page, + and this types it out once the terminal is in view. Screen readers get + the transcript as it is; the typed copy is hidden from them. Loaded + lazily; with reduced motion the transcript stays as written. */ +(function () { + "use strict"; + + var TYPE_MS = 28; + var LINE_MS = 90; + var PAUSE_MS = 700; + + function terminal(element, options) { + var pre = element.querySelector("pre"); + if (!pre || options.reduceMotion) { return; } + var lines = Array.prototype.map.call(pre.querySelectorAll("[data-line]"), function (line) { + return { kind: line.getAttribute("data-line"), node: line.cloneNode(true) }; + }); + if (!lines.length) { return; } + + // Keep the full transcript for assistive technology + var source = pre.cloneNode(true); + source.className = "sr-only"; + source.removeAttribute("tabindex"); + source.setAttribute("aria-label", pre.getAttribute("aria-label") || "Terminal transcript"); + pre.parentNode.insertBefore(source, pre); + pre.setAttribute("aria-hidden", "true"); + pre.removeAttribute("tabindex"); + + var replay = element.querySelector("[data-replay]"); + var timer = 0; + + function caret() { + var span = document.createElement("span"); + span.className = "caret"; + return span; + } + + function run() { + window.clearTimeout(timer); + pre.textContent = ""; + var index = 0; + var cursor = caret(); + pre.appendChild(cursor); + + function next() { + if (index >= lines.length) { + if (replay) { replay.hidden = false; } + return; + } + var line = lines[index]; + index += 1; + if (line.kind === "cmd") { + typeLine(line.node, function () { timer = window.setTimeout(next, PAUSE_MS); }); + } else { + pre.insertBefore(line.node.cloneNode(true), cursor); + timer = window.setTimeout(next, LINE_MS); + } + } + + function typeLine(node, done) { + var holder = document.createElement("span"); + var prompt = node.querySelector(".prompt"); + var command = node.querySelector(".cmd"); + var text = command ? command.textContent : node.textContent; + if (prompt) { holder.appendChild(prompt.cloneNode(true)); } + var typed = document.createElement("span"); + typed.className = "cmd"; + holder.appendChild(typed); + pre.insertBefore(holder, cursor); + var position = 0; + (function type() { + typed.textContent = text.slice(0, position); + position += 1; + if (position <= text.length) { + timer = window.setTimeout(type, TYPE_MS + Math.random() * 30); + } else { + pre.insertBefore(document.createTextNode("\n"), cursor); + done(); + } + })(); + } + + if (replay) { replay.hidden = true; } + timer = window.setTimeout(next, 400); + } + + if (replay) { replay.addEventListener("click", run); } + run(); + } + + window.KeelModules = window.KeelModules || {}; + window.KeelModules.terminal = terminal; +})(); diff --git a/cloud.html b/cloud.html new file mode 100644 index 0000000..ff29212 --- /dev/null +++ b/cloud.html @@ -0,0 +1,251 @@ + + + + + + Cloud and replication, Keel Linux + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+

Home / Cloud & replication

+ Cloud & replication +

From one box to a private cloud, on the same image

+

The appliance that runs alone is the one that joins a mesh. What changes is the mode chosen at first boot, and what the nodes write about themselves in a shared registry. Most of this page is decided and on the roadmap; each part says so.

+
+
+ +
+ + +
+
+

Installation modes

+

The installer uses two terms, simple and advanced installation, and offers three modes at first boot. Decided, 0028

+
+
+ +

Simple

+

Everything in one LXC. No mesh and no etcd. The data services are embedded on the machine.

+
+
+ +

Cloud simple

+

The database replicated from a primary to a standby; the application's files in a single LXC. Failover is by hand.

+
+
+ +

Cloud advanced

+

Three LXC at least, an etcd quorum and directory replication. Three sites, or two plus an external arbiter.

+
+
+
    +
  • The primary generates what the replica needs: the key, the password and the WireGuard endpoint. The replica is still configured on the replica.
  • +
  • When all three nodes of cloud advanced share one host, the installer says so in one line: three copies on one host fail together.
  • +
  • Installation by discovery. In cloud advanced, a new node asks for two things, the address of one node of the mesh and an entry secret. It reads the registry, lists what exists, such as a MariaDB, a Redis and two Nginx, and the operator selects what this machine consumes. Only the first appliance of a mesh takes real effort. Decided, 0026
  • +
+
+ +
+

The mesh is the base, not an extra

+

WireGuard in Core; the rest decided, 0024

+
    +
  • WireGuard ships in Keel Core, so every appliance can join a mesh, even when it is alone. keel network wireguard key prints this node's public key and never the private one.
  • +
  • Tunnels are initiated from inside to outside, so a node behind NAT or a firewall needs only outbound UDP.
  • +
  • IPv6 first. Native IPv6 goes direct between nodes, 2001:db8:a::10 to 2001:db8:c::10. IPv4-only nodes enter through a rendezvous point that translates, with NAT64.
  • +
  • Rendezvous points are nodes with a real edge and fixed addressing.
  • +
  • A service address on the mesh, for database appliances only for now. WireGuard is layer 3 with no ARP, so the address is a /128 route in AllowedIPs that a controller moves to the active node. Replication and node-to-node traffic always use the nodes' real addresses.
  • +
+

Open: the successor to HubDNS for nodes behind NAT whose IPv6 prefix changes. A PowerDNS overlay fed from etcd is the likely shape, not yet decided.

+
+ +
+

etcd is the mesh registry

+

Decided, 0025; Phase 5

+
    +
  • Every appliance announces itself in etcd: what it offers, where it is, and its site, which is the availability zone label.
  • +
  • etcd is control plane only. It holds cluster state, never application data; a few writes a minute tolerate a 30 ms round trip.
  • +
  • Quorum needs an odd number of voters. Two nodes cannot elect, so a third vote is required for automatic failover.
  • +
  • Site labels are enforced: two replicas of the same data are never placed in the same site.
  • +
  • etcd ships stopped, so a simple installation never runs it, and cloud simple does not either.
  • +
+
+ +
+

Databases: a primary and a hot standby

+

PostgreSQL, MariaDB and Redis are appliances of their own, paired as a primary and a standby. A MariaDB pair, with seeding of the replica and a read-only replica, is mostly done in keel 0.11, and keel database promote makes a replica a primary by hand. Partly built; Phase 2

+
    +
  • The standby is hot and serves no traffic until it is promoted.
  • +
  • Replication is synchronous: a commit is confirmed only after the standby has received it. PostgreSQL uses synchronous_commit; MariaDB uses semi-synchronous replication, waiting until the standby has received the transaction, not applied it.
  • +
  • Failover is the operator's decision in cloud simple, where two nodes cannot elect. In cloud advanced the election runs on the nodes themselves.
  • +
+

Chosen by the operator when the set is formed

+
+ + + + + + + +
ChoiceOptionsDefault
Failover: a replica takes over when the primary is lostautomatic, manualautomatic, active only once three voters are present
Rejoin: the old primary comes back as a replicaautomatic, manualmanual; automatic first keeps a copy of what the node held
Failback: the primary role returns to the original nodeautomatic, manual, nevermanual
+
+

Automatic failback is a goal: Keel implements it so that choosing it works, with one hard condition, that the role returns only after the original primary has fully caught up with what the standby wrote while it was primary. Goal, Phases 2 and 5

+
+ +
+

Directory replication

+

Decided, 0032; Phase 5

+
    +
  • A generic mesh service, delivered by a Syncthing overlay, in cloud advanced.
  • +
  • Each appliance declares the paths of its durable state, replicate:, and what to leave out, exclude:: sessions, caches, temporary files, locks, logs and rebuildable indexes.
  • +
  • With one writer, folders are send-only on the primary and receive-only on the standby, and flipped on promotion in the same step that promotes the database.
  • +
  • Sessions go to the database or to Redis, never to a replicated directory, and data with its own replication, such as a database or a search index, is never replicated by file.
  • +
+
+ +
+

Backup and object storage

+

Decided, 0037 and 0038; Phase 7

+
    +
  • Keel Backup is an appliance: a storage backend and a key service, installable as an LXC or a VM. TKLBAM stays as the client, with a backend abstraction so that it targets Keel Backup.
  • +
  • Off-site by rule. A backup destination is never in the same site as its data: it refuses, or in a simple installation warns loudly.
  • +
  • Taken from the standby, so the primary does not pay for it.
  • +
  • Restores are tested periodically into a throwaway container, and the result is reported to etcd.
  • +
  • Object storage on Garage, designed for nodes on unequal links, with its zones mapped to the site label and a replication factor of 3 across three zones. It is the recommended home for media, attachments and backups.
  • +
+
+ +
+

Monitoring

+

Decided, 0040

+
    +
  • Monit watches each machine and restarts what it can, with a limit per process so that a restart loop is reported rather than hidden. Its checks are generated from the manifest.
  • +
  • An agent writes a compact summary of Monit's state to the node's registry key; the site view is a view over etcd, and a node that stops writing its summary is the node that cannot speak for itself.
  • +
  • Prometheus is optional; the same agent can expose it.
  • +
+
+ +
+

What it does not promise

+
    +
  • Every commit pays the round trip to the standby. MariaDB falls back to asynchronous replication when no acknowledgement arrives in time, and a PostgreSQL primary with one synchronous standby stops committing while the standby is away. The timeout policy for both is still to be written.
  • +
  • Public clients find the service by DNS with a health check, and DNS takes minutes to follow a failover; some resolvers ignore low TTLs.
  • +
  • In an LXC container there is no watchdog, so fencing relies on the process demoting itself in time. A VM can have one.
  • +
  • Keel configures the role of each node. It does not design a database layout or a partition scheme.
  • +
+
+
+
+
+ + + + diff --git a/guidelines.html b/guidelines.html index 863f365..e1dde85 100644 --- a/guidelines.html +++ b/guidelines.html @@ -4,148 +4,199 @@ Guidelines, Keel Linux - + + - - + + - - - + + + + + + + + + + + + +
-
+
+

Home / Guidelines

Guidelines

How the project is organised

Short by design. The rules below apply to every repository under the organization.

+
+
+ +
+ -
-

One organization for everything

+
+
+

One organization for everything

    -
  • Infrastructure repositories keep their upstream names: common, fab, buildtasks, inithooks, confconsole, tkldev, webmin, tklbam, keel.
  • -
  • Appliance repositories carry the keel- prefix: keel-core, keel-wordpress, keel-odoo, keel-moodle, keel-lamp, keel-lapp, keel-nginx-php-fastcgi, keel-redis, keel-ejabberd.
  • +
  • Infrastructure repositories keep their upstream names: common, fab, buildtasks, inithooks, confconsole, tkldev, webmin, tklbam, and keel for the spec and the command.
  • +
  • Appliance repositories carry the keel- prefix: keel-core, keel-wordpress, keel-odoo, keel-mariadb, keel-postgresql, keel-redis, keel-moodle, keel-ejabberd.
  • +
  • Under the composition architecture, a component shared by two or more images is an overlay in common, shipped as its own package. keel-lamp, keel-lapp and keel-nginx-php-fastcgi stop being appliances and are kept as the history of the organization.
+
-

Issues

+
+

Issues and decisions

    -
  • Issues live in the repository they concern. There is no global tracker.
  • +
  • Issues live in the repository they concern.
  • +
  • Work that spans repositories, the order of work and the decisions live in the tracker. The roadmap is tracker#46.
  • +
  • Every direction taken is a numbered decision that says which earlier decisions it amends.
+
-

Tests and coverage

+
+

Tests and coverage

  • Every repository has tests.
  • Coverage is measured on every pull request in GitHub, and the check is required.
  • At least 90 percent for any repository; 95 percent for code the project writes.
  • A pull request that fails tests or the threshold cannot be merged.
  • +
  • A phase of the roadmap is done only when it holds on a built image, not on a machine assembled by hand.
+
-

Conventions

+
+

Conventions

  • IPv6 in every example and default, for instance [2001:db8:4b1::10]:443.
  • English in commits, code and documentation.
  • History is never rewritten.
  • System containers only.
+
-

Upstream

+
+

Upstream

  • Forks keep TurnKey Linux as an upstream remote.
  • Changes are offered back upstream where they apply.
  • +
  • What Keel packages that Debian lacks is offered to Debian, coordinating with those already packaging it.
+
-

The mark

-

The mark is a keel seen from astern, with the waterline across it and a drop below. It is drawn as a vector and every other size is an export of it; a change to the mark is a re-export, never an edit of an export.

+
+

The mark

+

The mark is a keel seen head on: two swept wings meeting at a mast, a waterline crossing where they meet, and a blade descending to a point. It is the part of a boat nobody sees and everything depends on. Every file is an export of the master drawing; a change to the mark is a new export, never an edit of an export.

+
+
The Keel Linux mark on a light background
+
The Keel Linux mark for a dark background
+
The Keel Linux mark in one colour
+
+
    +
  • Navy
    #0B2847, the symbol

  • +
  • Waterline
    #0072C9, the band only

  • +
  • Grey
    #7F8D9D, the subtitle only

  • +
-

Keep the clear space around it equal to the height of the drop, do not recolour it, and do not set it on a background that leaves the waterline invisible.

-
+

Keep a clear space equal to the height of the waterline band on every side. Do not recolour the mark, do not add a second blue, and do not stretch it. The symbol alone reads down to 16 pixels; below 140 pixels of width, use the symbol rather than the horizontal lockup.

+
- +
- - diff --git a/index.html b/index.html index eb3c30b..d931b46 100644 --- a/index.html +++ b/index.html @@ -3,195 +3,364 @@ - Keel Linux - + Keel Linux: composable, mesh-native appliances on Debian + + - - + + - - - + + + + + + + + + + + -
-
-
- Keel Linux icon: the cross-section of a hull with three layers stacked on a keel -
+
-

Keel Linux

-

Declarative, layered, IPv6-first system-container appliances, compatible with TurnKey Linux appliances.

-

A fork of TurnKey Linux 19 (Debian 13) that makes the LXC appliance behave like a cloud instance: described in one file, built from signed layers, reachable on its own address, and upgradable in place.

+ Early No end-user release yet. Read the status +

Composable, mesh-native appliances on Debian.

+

Every appliance is secure by default and can join a private cloud on day one.

+

A fork of TurnKey Linux 19 (Debian 13) that turns single-machine appliances into building blocks of a real cloud: small components that run alone or together, replicate across sites, fail over, and are upgraded with apt. IPv6-first, so every node has a routable address of its own, and declarative, so every machine is described by one YAML file that Keel applies and re-applies to the same result.

+
    +
  • IPv6-first
  • +
  • Declarative
  • +
  • WireGuard mesh
  • +
  • Upgraded with apt
  • +
+
+
+ + wg0 · mesh · IPv6 + +
+
+ +
+
    +
  • WireGuard tunnel
  • +
  • opened from inside a NAT
  • +
  • traffic between nodes
  • +
+
-
+
- What changes -

Four things an appliance should already do

-

TurnKey appliances are excellent on day zero and hard to keep on day two. Keel keeps the appliances and corrects the model around them.

-
    -
  • - 01 -

    Declared in one file

    -

    An instance spec describes hostname, IPv6 networking, domain and certificate policy, services, users and secrets by reference. Applying it converges the appliance and is safe to re-run. The interactive console writes the same file.

    +
    + What changes +

    Six things an appliance should already do

    +

    TurnKey appliances are excellent on day zero and built for one machine. Keel keeps the appliances and changes the model around them. Each point says how far it is built.

    +
    +
      +
    • +
      + + Decided +
      +

      Composed, not pre-cooked

      +

      PostgreSQL, MariaDB, Redis, Nginx and the language runtimes are components of their own, each able to run alone or serve another appliance. An appliance is a manifest that names what it is built on. LAMP and LAPP stop being images and become a recipe.

      +

      002300360041, the manifest

      +
    • +
    • +
      + + Partly built +
      +

      The mesh is built in

      +

      WireGuard ships in Keel Core, so every appliance can join a mesh, even alone. Tunnels are opened from the inside out, so a node behind NAT needs only outbound UDP. Native IPv6 goes direct, 2001:db8:a::10 to 2001:db8:b::10; IPv4-only nodes enter through a rendezvous point that translates.

      +

      0024How the mesh works

      +
    • +
    • +
      + + Partly built +
      +

      Secure by default

      +

      Layers are exported with no host keys, certificates or machine-id, and each machine makes its own at first boot. CrowdSec ships in Core; Keel Web adds a WAF and Anubis. Every listening port is declared loopback, mesh or public, and the firewall is derived from that.

      +

      00290030Security in detail

    • -
    • - 02 -

      Built from signed, content-addressed layers

      -

      Core, stack and app are separate read-only layers, each with a sha256, listed in a plain-text manifest signed by the project key and tied to a git commit. Same commit and same manifest give the same bytes.

      +
    • +
      + + Partly built +
      +

      A real cloud, not a single box

      +

      A primary and a hot standby, the standby seeded from the primary and kept read-only. Failover is the operator's call, and automatic failback after full catch-up is a goal. Directories replicate over the mesh, and an etcd registry records every node and its site.

      +

      003100320025

    • -
    • - 03 -

      Reachable end to end over IPv6

      -

      Every appliance gets a routable address of its own, such as 2001:db8:4b1::10, with an ACME certificate and host identity keys. No NAT traversal, no port mapping. IPv4 is optional and never assumed.

      +
    • +
      + + Working today +
      +

      One file is the truth

      +

      A YAML instance spec declares the machine: its name, IPv6 network, certificates, users, database role and monitoring. keel inspect writes it from a running machine, keel diff shows drift field by field, and keel spec apply converges. Re-applying changes nothing.

      +

      keel0027

    • -
    • - 04 -

      Maintainable across major versions

      -

      Platform upgrades and application schema upgrades are kept apart. The second runs with a return point, the upstream tool, validation and rollback, so an appliance follows Debian releases instead of being reinstalled.

      +
    • +
      + + Decided +
      +

      Upgraded with apt, backed up off-site

      +

      Everything Keel installs becomes a .deb in the Keel repository, on a stable or a testing track declared in the YAML. A database primary hands over before it upgrades. Backups go to a Keel Backup appliance in another site, and restores are tested on a schedule.

      +

      003900370038

-
+
- How it is built -

Three layers on one spine

-

An appliance is a core layer, a stack layer and an app layer, assembled from a manifest. The layers are built by the same tools TurnKey uses today, one deterministic tarball each.

-
-
- - Layer diagram - Three horizontal layers named app, stack and core are stacked on a vertical keel. A manifest to the right lists each layer with its sha256, the git commit and the signature, with a dashed line from each manifest entry to its layer. - - - - - +
+ Composition +

Small components, joined by the mesh

+

An application sits on a runtime, the runtime on Keel Web, and Keel Web on Keel Core. The data services stand beside the stack as appliances of their own, and the WireGuard mesh joins them.

+
+
+
+

An appliance, top to bottom

+
    +
  • ApplicationWordPress, Odoo, Nextcloud, Mastodon: a manifest and an inithookplanned
  • +
  • RuntimePHP-FPM, Python, Ruby, Node.js or Goplanned
  • +
  • Keel WebNginx, the Coraza WAF and Anubisfocus now
  • +
  • Keel CoreDebian 13, WireGuard, etcd, CrowdSec, the installerfocus now
  • +
+
+ +
+

Data services, beside it

+
    +
  • PostgreSQL, MariaDBa primary and a hot standby
  • +
  • Redissessions, queues and caches
  • +
  • Searchderived data, rebuilt when a node joins
  • +
  • Object storageGarage, for media, attachments and backups
  • +
+
+
+

In a simple installation the data services are embedded on the same machine. In an advanced one they are appliances of their own, found on the mesh. The composition in detail.

+
+
- - - - - - +
+
+
+ Installation modes +

One image, three ways to install it

+

The image is the same in every mode; the mode chosen at first boot decides what runs. Decided, 0028

+
+
    +
  • + +

    Simple

    +
      +
    • Everything in one LXC
    • +
    • No mesh and no etcd
    • +
    • CrowdSec installed and disabled
    • +
    • Keel Web is Nginx only
    • +
    +
  • +
  • + +

    Advanced, cloud simple

    +
      +
    • The database replicated from a primary to a standby
    • +
    • The application's files in a single LXC
    • +
    • The primary hands the standby its key, password and WireGuard endpoint
    • +
    • Two nodes cannot elect, so failover is by hand
    • +
    +
  • +
  • + +

    Advanced, cloud advanced

    +
      +
    • Three LXC at least, with an etcd quorum
    • +
    • Directory replication over the mesh
    • +
    • Three sites, or two plus an external arbiter
    • +
    • Later nodes install by discovery, not by typing
    • +
    +
  • +
+

Installation modes and replication in detail.

+
+
- - - - - - - - app - 133 MB - stack - 80 MB - core - 326 MB - +
+
+
+ Security +

Three gates before the application

+

In an advanced installation, a request meets CrowdSec first, then Nginx with the WAF inline, then Anubis, and only then the application. Decided, 0030

+
+
+
+
    +
  1. InternetA request arrives, over IPv6 first.
  2. +
  3. CrowdSecBans known bad addresses before anything else runs.
  4. +
  5. Nginx + WAFTerminates TLS, serves static files and runs Coraza with the OWASP Core Rule Set.
  6. +
  7. AnubisProof of work, behind Nginx and never at the edge. Search crawlers are exempt.
  8. +
  9. ApplicationOnly what passed the gates reaches it.
  10. +
+
+ banned by CrowdSec + refused by the WAF + challenged by Anubis + exempt crawler + In a simple installation Keel Web is Nginx only and CrowdSec is off. +
+
+

Keys, the pipeline, exposure classes and what each mode enables.

+
+
- - - - manifest - app sha256:9c1e... - stack sha256:4d7b... - core sha256:e3a0... - commit 1a2b3c4d - signed by project key - - -
Each layer is a deterministic tarball with a sha256 and a plain-text manifest. An update downloads only what changed: a new WordPress release means the app layer, not the base.
- -
-

Measured on the build host

-

Unmodified TurnKey 19.0 recipes, built as layers.

-
-
-
Core layer
-
326 MB
-
-
-
LAMP stack delta
-
80 MB
-
-
-
WordPress app delta
-
133 MB
-
-
-
WordPress rebuild on cached layers
-
69 s instead of 451 s
-
-
+
+
+
+ Declarative +

Declared in one file, converged by one command

+

The instance spec, /etc/keel/instance.yaml, is the machine written down. Keel reads the machine into it, compares the two and converges the machine to it.

+
    +
  • Inspect. Write a spec from a running machine, every field with its source, secrets never read.
  • +
  • Diff. Report drift field by field: same, drift, unknown or not declared.
  • +
  • Apply. Converge only what differs. A second run changes nothing.
  • +
  • Emit. Every installation will write its complete YAML, defaults included, so one answer file replays many installations. Decided, 0027
  • +
+
+
+
+
+ + root@blog: ~ + +
+
$ keel inspect --output /etc/keel/instance.yaml
+instance.hostname: blog (from /etc/hostname)
+instance.fqdn: shop.example.org (from /etc/hosts)
+network.interfaces.eth0.ipv6: static 2001:db8:1::10/64 gateway fe80::1
+inspect: 20 inferred, 1 not inferred (0 required), 2 secrets to provide; spec complete
+# declare the name this machine should have
+$ sed -i 's/shop.example.org/blog.example.org/' /etc/keel/instance.yaml
+$ keel diff
+instance.hostname: same (blog)
+instance.fqdn: drift (declared blog.example.org, observed shop.example.org)
+network.interfaces.eth0.ipv6.address: same (2001:db8:1::10/64)
+diff: 12 same, 1 drift, 0 unknown, 1 not declared, 2 not compared; drift found
+$ keel spec apply --system-only
+apply --system-only: /etc/inithooks.conf not read or written
+instance.fqdn: write /etc/hosts with '127.0.1.1 blog.example.org blog' (mode 0644): done
+apply --system-only: 1 change(s), 0 failed
+$ keel diff | tail -n 1
+diff: 13 same, 0 drift, 0 unknown, 1 not declared, 2 not compared; no drift
+
+

Commands and output lines as the keel documentation gives them; the session is abridged.

+
+
+
+ +
+
+
+ How it is built +

Signed, content-addressed layers

+

Every appliance is assembled from read-only layers. Each is a deterministic tarball with a sha256, listed in a plain-text layer manifest that is signed by the project key and tied to a git commit.

+
    +
  • Reproducible. The same commit and the same manifest give the same bytes.
  • +
  • Incremental. An update downloads only the layer that changed: a new WordPress release is the app layer, not the base.
  • +
  • Verified. keel pull checks every digest, and keel verify checks installed layers against their manifests.
  • +
+
+
+

Measured on the build host

+

Unmodified TurnKey 19.0 recipes built as layers, before the composition redesign. One build host, one measurement each.

+
    +
  • Core layer326 MB

  • +
  • LAMP stack delta80 MB

  • +
  • WordPress app delta133 MB

  • +
+
    +
  • WordPress, rebuilt on cached layers69 s

  • +
  • WordPress, built from scratch451 s

  • +
-
+
- Standards -

Rules every change follows

-
    +
    + Standards +

    Rules every change follows

    +
    +
    • - 90 percent test coverage minimum, 95 percent for project code -

      Every change ships with tests. The forked repositories keep at least 90 percent coverage; code the project writes keeps 95 percent.

      + 90 percent test coverage minimum, 95 for project code +

      Every change ships with tests. The forked repositories keep at least 90 percent coverage; code the project writes keeps 95 percent. The check is required on every pull request.

    • IPv6 in every example @@ -213,61 +382,76 @@

      Rules every change follows

-
+
+
+
+ Status +

Early, and plain about it

+

There is no release for end users. Layers, Proxmox templates and packages are served from mirror.keellinux.org and archive.keellinux.org for testing, and the test images are not meant for production.

+

The build host reproduces unmodified TurnKey 19.0 from the forked repositories, which is the baseline every change is measured against. keel inspect, keel diff and keel spec apply run on the test images, and a MariaDB pair, with seeding and a read-only replica, is mostly done in keel 0.11.

+

The composition architecture on this site was decided on 30 September 2026. Where a point says decided or goal, it is on the roadmap and not yet in the code.

+ +
+
+

The focus now

+
    +
  1. P1
    Keel Core

    WireGuard is in Core. Next: CrowdSec installed and disabled, etcd present and stopped, the YAML always emitted, overlays shipped as .deb.

  2. +
  3. P3
    Keel Web

    Nginx, Coraza and Anubis packaged in the Keel repository; plain Nginx in a simple installation, the full pipeline in an advanced one.

  4. +
  5. 0041
    The appliance manifest

    Version 1 is decided. The first step is its reader, keel manifest validate and keel manifest show, with Core and Web as fixtures.

  6. +
+

Decisions: 0023 to 0040 on the tracker, and the manifest, tracker#39.

+
+
+
+ +
- Status -

Early

-
-

The build host reproduces unmodified TurnKey 19.0 from the forked repositories. That is the baseline every later change is measured against, and it passed before anything else started.

-

Layers, Proxmox templates and packages are served from mirror.keellinux.org and archive.keellinux.org for testing. There is no release for end users yet, and the test images are not meant for production.

+
+

Read how it fits together

+

The architecture, the security model and the cloud are each on a page of their own, with every claim tied to the code or to a decision on the tracker.

+
-

What is next

-
    -
  1. Declarative spec. keel inspect, keel diff and keel spec apply exist and run on the test images. Next: converging the network on a running machine, and the console writing the same file.
  2. -
  3. Layered builds. Hashed core, stack and app layers with signed manifests are built and served for testing. Next: making them something end users can rely on.
  4. -
  5. Transition from TurnKey 19.0. A keyring package and a transition package that switch an existing appliance to the project repositories and write its spec from what is installed.
  6. -
- - diff --git a/roadmap.html b/roadmap.html new file mode 100644 index 0000000..b1d5d61 --- /dev/null +++ b/roadmap.html @@ -0,0 +1,281 @@ + + + + + + Roadmap, Keel Linux + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+

Home / Roadmap

+ Roadmap +

One layer at a time

+

Every phase ships something testable, and a phase is done only when its criterion holds on a built image, not on a machine assembled by hand. The focus now is Keel Core and Keel Web.

+

A copy of tracker#46 as of 2026-09-30. The tracker is the source; where the two differ, the tracker is right. Decision numbers link to their issue on the tracker.

+
+
+ +
+ + +
    +
  1. + +
    +
    +

    Phase 0: sharpen the axe

    +

    0 of 5 done

    +
    +
      +
    • Record the decisions, 0023 to 0040
    • +
    • Map them against 0000 to 0022; the maintainer confirms the review notes
    • +
    • Specify the manifest format, the pivot of backup, monitoring, upgrade, replication and discovery (tracker#39; version 1 decided in 0041)
    • +
    • The maintainer validates the WordPress test image for simple and cloud simple
    • +
    • An attended release replaces the published layers that still carry private keys
    • +
    +

    Done when the manifest schema is reviewed with WordPress, Odoo and Mastodon examples, the WordPress test image passes the maintainer's test, and no published layer carries a shared key.

    +
    +
  2. + +
  3. + +
    +
    +

    Phase 1: Keel Core

    +

    1 of 7 done

    +
    +
      +
    • WireGuard in Core (0024)
    • +
    • CrowdSec in Core, installed and disabled (0029)
    • +
    • etcd overlay present and stopped (0025, 0036)
    • +
    • YAML always emitted, complete with defaults (0027)
    • +
    • stable and testing tracks declared in the YAML (0039)
    • +
    • Overlays shipped as .deb from the Keel repository (0036, 0039)
    • +
    • Simple and advanced mode screen (0028)
    • +
    +

    Done when a Core image boots in simple installation, emits a YAML that re-applies as a no-op, runs no etcd and no active CrowdSec, and upgrades one overlay with apt upgrade on the testing track.

    +
    +
  4. + +
  5. + +
    +
    +

    Phase 2: data appliances, standalone and paired

    +

    0 of 5 done

    +
    +
      +
    • MariaDB standalone and paired; the pair, seeding and read-only replica are mostly done in keel 0.11
    • +
    • PostgreSQL standalone and paired
    • +
    • Redis standalone and paired
    • +
    • The semi-synchronous setup for MariaDB and the timeout policy for both engines (0031)
    • +
    • Automatic failback after full catch-up, for the pair; the default stays the operator's, manual (0031)
    • +
    +

    Done when each engine passes a two-container gate in cloud simple: write on the primary, read on the standby, commit latency and the synchronous or semi-synchronous fallback measured, promotion by hand.

    +
    +
  6. + +
  7. + +
    +
    +

    Phase 3: Keel Web

    +

    0 of 3 done

    +
    +
      +
    • WAF: the Coraza connector, ModSecurity v3, or Coraza as a proxy (0030)
    • +
    • Anubis packaged in the Keel repository
    • +
    • nginx overlay and the Keel Web recipe; Coraza and Anubis disabled in simple
    • +
    +

    Done when Keel Web in advanced mode blocks a Core Rule Set test payload, challenges a browser through Anubis, lets exempt crawlers through, and in simple mode is plain Nginx.

    +
    +
  8. + +
  9. + +
    +
    +

    Phase 4: runtimes, and WordPress on Keel PHP

    +

    0 of 3 done

    +
    +
      +
    • php-fpm overlay; Keel PHP is Keel Web plus php-fpm (0023)
    • +
    • WordPress as the first application built from a manifest, which retires Apache for WordPress (0034)
    • +
    • python, ruby, nodejs and go overlays (0036)
    • +
    +

    Done when WordPress on Keel PHP passes the appliance gate in simple and cloud simple, built from its manifest, with no Apache in the image.

    +
    +
  10. + +
  11. + +
    +
    +

    Phase 5: mesh control plane

    +

    0 of 7 done

    +
    +
      +
    • etcd registry: key layout and node announcement (0025)
    • +
    • Installation by discovery (0026)
    • +
    • Cloud advanced on three nodes, election and fencing (0028)
    • +
    • Automatic failback after full catch-up on three nodes; the operator still chooses, default manual (0031)
    • +
    • Service address controller rewriting AllowedIPs from etcd (0029)
    • +
    • Syncthing overlay, send-only and receive-only flipped on promotion (0032)
    • +
    • Monit summary to etcd, mesh alerting by watch (0040)
    • +
    +

    Done when a three-container gate installs the second and third node by discovery, fails over automatically, moves the service address, flips the Syncthing folders, fails back after catch-up, and reports each node's Monit summary through etcd.

    +
    +
  12. + +
  13. + +
    +
    +

    Phase 6: applications by manifest

    +

    0 of 3 done

    +
    +
      +
    • Odoo by manifest, updated with apt (0039)
    • +
    • Nextcloud by manifest
    • +
    • Mastodon as the reference proof (0035)
    • +
    +

    Done when each passes the gate in simple and cloud advanced from its manifest alone plus its inithook, Mastodon last.

    +
    +
  14. + +
  15. + +
    +
    +

    Phase 7: object storage and backup

    +

    0 of 4 done

    +
    +
      +
    • Garage packaged; s3 overlay with zones from the site label (0038)
    • +
    • Keel Backup appliance (0037)
    • +
    • TKLBAM backend abstraction targeting Keel Backup
    • +
    • Periodic restore test into a throwaway container, reported to etcd
    • +
    +

    Done when an application is backed up from its standby to Keel Backup in another site, and a scheduled restore test succeeds and is visible in etcd.

    +
    +
  16. + +
  17. + +
    +
    +

    Open architecture items

    +
    +
      +
    • DNS successor for nodes behind NAT with changing IPv6 prefixes; PowerDNS fed from etcd is likely, not decided (0024)
    • +
    • Elasticsearch or OpenSearch (0033)
    • +
    +
    +
  18. +
+
+
+ + + + diff --git a/security.html b/security.html new file mode 100644 index 0000000..3db85bb --- /dev/null +++ b/security.html @@ -0,0 +1,242 @@ + + + + + + Security, Keel Linux + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+

Home / Security

+ Security +

Secure by default, and clear about what is built

+

Security in Keel is a set of defaults an operator does not have to remember: no shared secrets in an image, a filter in front of every web application, and no port open that nobody declared. Each part below says whether it is in the code or decided and on the roadmap.

+
+
+ +
+ + +
+
+

Keys and identity: none in the image

+

An image that carries a private key hands that key to everyone who downloads it. Keel removes them in three places, so that none depends on another being remembered. Built; test layers to be republished

+
    +
  • The build removes them. SSH host keys, the TLS key and certificate, Webmin's key and the snakeoil pair are removed before a layer is exported, and every layer is exported with an empty machine-id.
  • +
  • The export refuses them. The layer export audits the tree and refuses one that still holds a private key.
  • +
  • Every boot replaces them. A unit that runs before SSH, Webmin, the web servers and PostgreSQL generates what is missing and replaces any key on the list of published ones.
  • +
+
+ Not done yet +

The test layers on the mirror were built before the fix, and an attended release replaces them (Phase 0 of the roadmap). Until then the boot unit is what protects a machine made from one. ISO and VM images still ship the build's machine-id (tracker#24).

+
+
+ +
+

The request pipeline

+

Keel Web is an appliance of its own and the base of every web appliance: Nginx, the Coraza WAF and Anubis. In an advanced installation a request passes them in a fixed order. Decided, 0029 and 0030; Phases 1 and 3

+
+
+
    +
  1. InternetA request arrives, over IPv6 first.
  2. +
  3. CrowdSecBans known bad addresses first, with its firewall bouncer.
  4. +
  5. Nginx + WAFTerminates TLS, serves static files and runs Coraza inline with the OWASP Core Rule Set.
  6. +
  7. AnubisProof of work, behind Nginx and never at the edge. Googlebot and Bingbot are exempt.
  8. +
  9. ApplicationOnly what passed the gates reaches it.
  10. +
+
+ banned by CrowdSec + refused by the WAF + challenged by Anubis + exempt crawler + An illustration, not a measurement. +
+
+

Why this order

+
    +
  • CrowdSec bans first, so an address already known to be hostile costs nothing further. It ships in Keel Core, installed in every mode and disabled in a simple installation.
  • +
  • The WAF runs inside Nginx. Its health check is not that it loaded but that it blocks: a Core Rule Set test payload must get 403. If Coraza's Nginx connector proves unusable, the known fallbacks are ModSecurity v3 with the same Rule Set, or Coraza as a proxy; choosing one would be a new decision.
  • +
  • Anubis sits behind Nginx. It listens on the loopback only, and one signing key is shared by every node of a set, so a failover does not make every visitor solve the challenge again.
  • +
+
+ +
+

What each installation mode enables

+

The image is the same in every mode; the mode decides what runs. These are the default states of the version 1 manifests of Keel Core and Keel Web. Decided, 0041

+
+ + + + + + + + + + + +
OverlayApplianceSimpleCloud simpleCloud advanced
installerCoreenabledenabledenabled
wireguardCoredisabledenabledenabled
etcdCoredisableddisabledenabled
crowdsecCoredisabledenabledenabled
nginxWebenabledenabledenabled
corazaWebdisabledenabledenabled
anubisWebdisabledenabledenabled
+
+

An operator can depart from a default on one machine, in the spec. A disabled overlay is still installed, so turning it on is a change to the spec, not a new image.

+
+ +
+

Every port has an exposure class

+

Every listening port in a manifest carries one of three classes, and the firewall is derived from them rather than written by hand. A port is reserved across the whole chain of appliances, whatever its state, since an operator can turn a disabled overlay on. Decided, 0041

+
+
loopback

Loopback

Only the machine itself: postfix on 25, Anubis behind Nginx.

+
mesh

Mesh

Only other nodes, over WireGuard: etcd on 2379 and 2380.

+
public

Public

Anyone: SSH on 22, the web shell on 12320, Webmin on 12321, Nginx on 80 and 443.

+
+
# /usr/share/keel/overlays/nginx.yaml, from keel-overlay-nginx
+manifest_version: 1
+kind: overlay
+name: nginx
+processes:
+  - name: nginx
+    unit: nginx.service
+    listen:
+      - {port: 80, protocol: tcp, expose: public}
+      - {port: 443, protocol: tcp, expose: public}
+checks:
+  - {name: nginx, process: nginx, type: http, address: loopback, port: 80,
+     path: /keel-health, expect: 204, on_failure: restart}
+
+ +
+

Network changes that undo themselves

+

A machine reached only over SSH is one bad network change away from being unreachable. In keel

+
    +
  • On a VM, a network change made by keel spec apply --system reverts by itself unless keel network confirm arrives from a new session over the new configuration. In an LXC container the host owns the network; Keel compares it and does not change it.
  • +
  • The WireGuard overlay is converged under the same confirmation window. The one exception, decided in 0029, is a move of a database appliance's service address on the mesh, which changes neither the node's uplink nor its own overlay address.
  • +
  • The network is changed in the console or at the command line, never through Webmin, which shows it read-only.
  • +
+
+ +
+

Secrets are references, never values

+
    +
  • The spec names a secret by the file that holds it, and keel spec validate checks that the file exists with the right owner and mode.
  • +
  • keel inspect and keel diff never read a secret, on either side, and a private WireGuard key is never printed.
  • +
  • A manifest declares each secret by name with a policy: whether it may, must or must never be generated, and whether every node of a set shares it. The primary generates what the replica needs. Decided, 0028 and 0041
  • +
+
+ +
+

Where packages come from

+

Every image rebuilds from the project's own archive: a dated, pinned pool, verified by the build before it is read. What Debian 13 does not carry, Keel builds from source and signs in the same repository: Coraza's Nginx connector, Anubis and Garage. Anubis and Garage have packaging in progress in Debian, and Keel coordinates with those efforts rather than packaging in parallel. Decided, 0039

+
+
+
+
+ + + + diff --git a/style.css b/style.css index 8d13cf1..0b27849 100644 --- a/style.css +++ b/style.css @@ -1,78 +1,119 @@ -/* Keel Linux project site. Plain CSS, system fonts, no build step. */ +/* Keel Linux project site. Plain CSS, system fonts, no build step. + Colours: the brand's navy and waterline blue (handbook docs/brand), the + site's text navy #0B1F3A and off-white #F4F7FA, and teal for the mesh. */ :root { color-scheme: light dark; --navy: #0B1F3A; + --brand-navy: #0B2847; + --waterline: #0072C9; --teal: #16A6A0; --offwhite: #F4F7FA; - --bg: var(--offwhite); - --fg: var(--navy); - --muted: #3F5470; - --accent: var(--teal); - --accent-text: #0C6F6B; + --bg: #F7F9FC; + --bg-alt: #EDF2F8; --surface: #FFFFFF; - --line: #D3DCE6; + --surface-2: #F2F6FA; + --fg: var(--navy); + --muted: #44576F; + --faint: #55667E; + --line: #D6DFE9; + --line-strong: #B9C7D6; + --accent: var(--waterline); + --accent-text: #005A9E; + --accent-soft: #E3F0FB; + --mesh: #0A6B66; + --mesh-soft: #DDF3F1; + --warn: #8A5300; + --warn-soft: #FDF1DC; + --stop: #B42318; --plate: var(--offwhite); --btn-bg: var(--navy); --btn-fg: var(--offwhite); - --code-bg: #E7EDF4; - --focus: #0C6F6B; - - --gutter: 16px; - --measure: 62ch; - --radius: 0.75rem; + --code-bg: #E8EEF5; + --term-bg: #0B1B30; + --term-fg: #D9E4F0; + --focus: #005A9E; + --shadow: 0 1px 2px rgb(11 31 58 / 6%), 0 8px 24px rgb(11 31 58 / 6%); + --shadow-lg: 0 2px 4px rgb(11 31 58 / 6%), 0 24px 48px rgb(11 31 58 / 10%); + + --gutter: 24px; + --wrap: 1200px; + --measure: 64ch; + --radius: 14px; + --radius-sm: 8px; + --header-h: 64px; + --mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", + "DejaVu Sans Mono", monospace; } @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { - --bg: var(--navy); - --fg: var(--offwhite); - --muted: #B5C2D2; - --accent: var(--teal); - --accent-text: #2FBDB7; - --surface: #112A4C; - --line: #28466B; - --plate: var(--offwhite); - --btn-bg: var(--teal); - --btn-fg: var(--navy); - --code-bg: #163559; - --focus: var(--teal); + --bg: #07182B; + --bg-alt: #0A1F37; + --surface: #0E2540; + --surface-2: #112C4B; + --fg: #EEF3F8; + --muted: #AFBED0; + --faint: #93A5BB; + --line: #1F3D63; + --line-strong: #2C5382; + --accent: #4DA8F0; + --accent-text: #7DC0F5; + --accent-soft: #12355A; + --mesh: #3CCBC4; + --mesh-soft: #0F3B45; + --warn: #F2C36B; + --warn-soft: #3A2C12; + --stop: #FF8A7A; + --btn-bg: #4DA8F0; + --btn-fg: #06172A; + --code-bg: #15365C; + --term-bg: #050F1D; + --focus: #7DC0F5; + --shadow: 0 1px 2px rgb(0 0 0 / 30%), 0 8px 24px rgb(0 0 0 / 25%); + --shadow-lg: 0 2px 4px rgb(0 0 0 / 30%), 0 24px 48px rgb(0 0 0 / 35%); } } :root[data-theme="dark"] { - --bg: var(--navy); - --fg: var(--offwhite); - --muted: #B5C2D2; - --accent: var(--teal); - --accent-text: #2FBDB7; - --surface: #112A4C; - --line: #28466B; - --plate: var(--offwhite); - --btn-bg: var(--teal); - --btn-fg: var(--navy); - --code-bg: #163559; - --focus: var(--teal); + --bg: #07182B; + --bg-alt: #0A1F37; + --surface: #0E2540; + --surface-2: #112C4B; + --fg: #EEF3F8; + --muted: #AFBED0; + --faint: #93A5BB; + --line: #1F3D63; + --line-strong: #2C5382; + --accent: #4DA8F0; + --accent-text: #7DC0F5; + --accent-soft: #12355A; + --mesh: #3CCBC4; + --mesh-soft: #0F3B45; + --warn: #F2C36B; + --warn-soft: #3A2C12; + --stop: #FF8A7A; + --btn-bg: #4DA8F0; + --btn-fg: #06172A; + --code-bg: #15365C; + --term-bg: #050F1D; + --focus: #7DC0F5; + --shadow: 0 1px 2px rgb(0 0 0 / 30%), 0 8px 24px rgb(0 0 0 / 25%); + --shadow-lg: 0 2px 4px rgb(0 0 0 / 30%), 0 24px 48px rgb(0 0 0 / 35%); } /* Base */ -*, -*::before, -*::after { - box-sizing: border-box; -} +[hidden] { display: none !important; } -html { - -webkit-text-size-adjust: 100%; -} +*, *::before, *::after { box-sizing: border-box; } + +html { -webkit-text-size-adjust: 100%; scroll-padding-top: calc(var(--header-h) + 16px); } @media (prefers-reduced-motion: no-preference) { - html { - scroll-behavior: smooth; - } + html { scroll-behavior: smooth; } } body { @@ -81,858 +122,528 @@ body { color: var(--fg); font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif; - font-size: 1rem; - line-height: 1.6; + font-size: 1.0625rem; + line-height: 1.65; overflow-x: hidden; + text-rendering: optimizeLegibility; } -img, -svg { - max-width: 100%; - height: auto; - display: block; -} - -a { - color: var(--accent-text); - text-underline-offset: 0.15em; -} +img, svg, canvas { max-width: 100%; display: block; } +img { height: auto; } -a:hover { - text-decoration-thickness: 2px; -} +a { color: var(--accent-text); text-underline-offset: 0.18em; text-decoration-thickness: 1px; } +a:hover { text-decoration-thickness: 2px; } -:focus-visible { - outline: 3px solid var(--focus); - outline-offset: 3px; - border-radius: 2px; -} +:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; border-radius: 4px; } +code, pre, kbd { font-family: var(--mono); } code { - font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, - "Liberation Mono", "DejaVu Sans Mono", monospace; - font-size: 0.92em; + font-size: 0.88em; background: var(--code-bg); - padding: 0.1em 0.4em; + padding: 0.1em 0.38em; border-radius: 0.3em; + overflow-wrap: anywhere; } -h1, -h2, -h3 { - line-height: 1.2; - letter-spacing: -0.01em; - margin: 0; -} +h1, h2, h3, h4 { line-height: 1.15; margin: 0 0 0.5em; letter-spacing: -0.02em; font-weight: 750; } +h1 { font-size: clamp(2.2rem, 1.5rem + 2.6vw, 3.5rem); letter-spacing: -0.035em; } +h2 { font-size: clamp(1.75rem, 1.35rem + 1.6vw, 2.6rem); letter-spacing: -0.03em; } +h3 { font-size: 1.2rem; letter-spacing: -0.01em; } +h4 { font-size: 1rem; letter-spacing: 0; } +p { margin: 0 0 1em; } +ul, ol { margin: 0 0 1em; padding-left: 1.25em; } -p { - margin: 0; -} - -p + p { - margin-top: 1em; -} - -.wrap { - max-width: 72rem; - margin-inline: auto; - padding-inline: var(--gutter); -} - -@media (min-width: 48rem) { - .wrap { - padding-inline: 2rem; - } -} +.wrap { max-width: var(--wrap); margin: 0 auto; padding: 0 var(--gutter); } .skip { - position: absolute; - left: var(--gutter); - top: -100%; - padding: 0.5rem 1rem; - background: var(--surface); - color: var(--fg); - border: 1px solid var(--line); - border-radius: 0.5rem; - z-index: 10; + position: absolute; left: 12px; top: -64px; z-index: 100; + background: var(--btn-bg); color: var(--btn-fg); + padding: 0.6rem 1rem; border-radius: var(--radius-sm); font-weight: 650; } +.skip:focus { top: 12px; } -.skip:focus { - top: 0.75rem; -} - -.visually-hidden { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip: rect(0 0 0 0); - clip-path: inset(50%); - white-space: nowrap; +.sr-only { + position: absolute !important; width: 1px; height: 1px; margin: -1px; + padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } /* Header */ .site-header { + position: sticky; top: 0; z-index: 50; + background: color-mix(in srgb, var(--bg) 86%, transparent); + backdrop-filter: saturate(160%) blur(12px); + -webkit-backdrop-filter: saturate(160%) blur(12px); border-bottom: 1px solid var(--line); } - -.site-header .wrap { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.75rem 1.5rem; - min-height: 4rem; - padding-block: 0.75rem; -} +.site-header .wrap { display: flex; align-items: center; gap: 1rem; min-height: var(--header-h); } .brand { - display: inline-flex; - align-items: center; - gap: 0.6rem; - color: var(--fg); - text-decoration: none; - font-weight: 700; - font-size: 1.05rem; - margin-right: auto; -} - -.brand img { - width: 2rem; - height: 2rem; - background: var(--plate); - border-radius: 0.4rem; -} - -.site-nav ul { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-wrap: wrap; - gap: 0.25rem 1.25rem; + display: inline-flex; align-items: center; gap: 0.6rem; + color: var(--fg); text-decoration: none; font-weight: 700; font-size: 1.05rem; + margin-right: auto; white-space: nowrap; } +.brand img { width: 2rem; height: 2rem; background: var(--plate); border-radius: 0.4rem; padding: 2px; } +.site-nav ul { display: flex; flex-wrap: wrap; gap: 0.25rem; list-style: none; margin: 0; padding: 0; } .site-nav a { - color: var(--fg); - text-decoration: none; - font-size: 0.95rem; -} - -.site-nav a:hover { - text-decoration: underline; - text-decoration-color: var(--accent); - text-decoration-thickness: 2px; -} - -.theme-toggle { - appearance: none; - border: 1px solid var(--line); - background: transparent; - color: var(--fg); - width: 2.25rem; - height: 2.25rem; - border-radius: 50%; - display: inline-grid; - place-items: center; - cursor: pointer; - padding: 0; -} - -.theme-toggle:hover { - border-color: var(--accent); -} - -.theme-toggle svg { - width: 1.1rem; - height: 1.1rem; + display: block; padding: 0.45rem 0.7rem; border-radius: var(--radius-sm); + color: var(--muted); text-decoration: none; font-size: 0.95rem; font-weight: 550; +} +.site-nav a:hover { color: var(--fg); background: var(--surface-2); } +.site-nav a[aria-current="page"] { color: var(--fg); background: var(--accent-soft); } + +.icon-btn { + display: inline-grid; place-items: center; width: 40px; height: 40px; + border: 1px solid var(--line); border-radius: 10px; background: var(--surface); + color: var(--fg); cursor: pointer; padding: 0; +} +.icon-btn:hover { border-color: var(--line-strong); } +.icon-btn svg { width: 20px; height: 20px; } +.nav-toggle { display: none; } + +@media (max-width: 900px) { + .js .nav-toggle { display: inline-grid; } + .js .site-nav { + position: absolute; left: 0; right: 0; top: 100%; + background: var(--bg); border-bottom: 1px solid var(--line); + box-shadow: var(--shadow-lg); display: none; + } + .js .site-nav.is-open { display: block; } + .js .site-nav ul { flex-direction: column; padding: 0.5rem var(--gutter) 1rem; } + .js .site-nav a { padding: 0.75rem 0.5rem; font-size: 1rem; } + html:not(.js) .site-header .wrap { flex-wrap: wrap; padding-bottom: 0.5rem; } } -/* Buttons */ - -.actions { - display: flex; - flex-wrap: wrap; - gap: 0.75rem; - margin-top: 2rem; -} +/* Buttons, chips, badges */ .btn { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.7rem 1.25rem; - border-radius: 0.5rem; - border: 1.5px solid var(--btn-bg); - font-weight: 600; - text-decoration: none; - line-height: 1.2; -} - -.btn-primary { - background: var(--btn-bg); - color: var(--btn-fg); + display: inline-flex; align-items: center; gap: 0.5rem; + padding: 0.8rem 1.25rem; border-radius: 10px; font-weight: 650; font-size: 1rem; + text-decoration: none; border: 1px solid transparent; line-height: 1.2; + transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease; } +.btn-primary { background: var(--btn-bg); color: var(--btn-fg); box-shadow: var(--shadow); } +.btn-primary:hover { transform: translateY(-1px); box-shadow: var(--shadow-lg); } +.btn-ghost { color: var(--fg); border-color: var(--line-strong); background: var(--surface); } +.btn-ghost:hover { border-color: var(--accent); } +.btn svg { width: 18px; height: 18px; } -.btn-primary:hover { - opacity: 0.92; +.chips { display: flex; flex-wrap: wrap; gap: 0.5rem; list-style: none; margin: 0; padding: 0; } +.chip { + display: inline-flex; align-items: center; gap: 0.4rem; + padding: 0.3rem 0.75rem; border-radius: 999px; font-size: 0.85rem; font-weight: 600; + background: var(--surface); border: 1px solid var(--line); color: var(--muted); } +.chip::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--mesh); } -.btn-secondary { - background: transparent; - color: var(--fg); - border-color: var(--fg); +.badge { + display: inline-flex; align-items: center; gap: 0.35rem; white-space: nowrap; + padding: 0.2rem 0.6rem; border-radius: 999px; font-size: 0.78rem; font-weight: 700; + letter-spacing: 0.01em; background: var(--surface-2); color: var(--muted); } +.badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; } +.badge-live { background: var(--mesh-soft); color: var(--mesh); } +.badge-partial { background: var(--warn-soft); color: var(--warn); } +.badge-decided { background: var(--accent-soft); color: var(--accent-text); } +.badge-goal { background: var(--surface-2); color: var(--muted); } -.btn-secondary:hover { - border-color: var(--accent); - color: var(--accent-text); +.eyebrow { + display: inline-block; font-size: 0.8rem; font-weight: 750; letter-spacing: 0.12em; + text-transform: uppercase; color: var(--accent-text); margin-bottom: 0.9rem; } +.lead { font-size: clamp(1.1rem, 1rem + 0.35vw, 1.3rem); color: var(--muted); max-width: var(--measure); } /* Sections */ -section { - padding-block: 4rem; - border-top: 1px solid var(--line); -} +.section { padding: clamp(64px, 5rem + 2vw, 112px) 0; } +.section-alt { background: var(--bg-alt); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } +.section-head { max-width: 760px; margin-bottom: clamp(2rem, 1.5rem + 1.5vw, 3.25rem); } +.section-head.center { margin-left: auto; margin-right: auto; text-align: center; } +.section-head.center .lead { margin-left: auto; margin-right: auto; } -@media (min-width: 48rem) { - section { - padding-block: 5.5rem; - } -} - -.eyebrow { - display: block; - color: var(--accent-text); - font-size: 0.8rem; - font-weight: 700; - letter-spacing: 0.1em; - text-transform: uppercase; - margin-bottom: 0.75rem; -} +.grid { display: grid; gap: 1.25rem; } +.grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } +@media (max-width: 1000px) { .grid-3 { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +@media (max-width: 680px) { .grid-2, .grid-3 { grid-template-columns: minmax(0, 1fr); } } -h2 { - font-size: clamp(1.6rem, 1.2rem + 1.8vw, 2.25rem); -} - -.lead { - color: var(--muted); - font-size: 1.1rem; - max-width: var(--measure); - margin-top: 1rem; -} +.card { + background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); + padding: 1.6rem; box-shadow: var(--shadow); min-width: 0; + transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; +} +a.card { color: inherit; text-decoration: none; display: block; } +a.card:hover, .card.lift:hover { transform: translateY(-3px); border-color: var(--line-strong); box-shadow: var(--shadow-lg); } +.card h3 { margin-top: 0.9rem; } +.card p { color: var(--muted); } +.card p:last-child { margin-bottom: 0; } +ul.grid { list-style: none; padding: 0; margin: 0; } + +.card-top { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; flex-wrap: wrap; } +.icon { + display: inline-grid; place-items: center; width: 44px; height: 44px; border-radius: 12px; + background: var(--accent-soft); color: var(--accent-text); +} +.icon svg { width: 22px; height: 22px; } +.refs { margin-top: 1rem; font-size: 0.85rem; color: var(--faint); display: flex; flex-wrap: wrap; gap: 0.4rem 0.75rem; } +.refs a { color: var(--faint); } +.refs a:hover { color: var(--accent-text); } /* Hero */ -.hero { - border-top: 0; - padding-block: 4rem 4.5rem; -} - -@media (min-width: 48rem) { - .hero { - padding-block: 6rem 7rem; - } -} - -.hero .wrap { - display: grid; - gap: 2rem; - align-items: center; -} - -@media (min-width: 40rem) { - .hero .wrap { - grid-template-columns: auto 1fr; - gap: 3rem; - } -} - -.plate { - width: 8.5rem; - aspect-ratio: 1; - border-radius: 1.5rem; - background: var(--plate); - border: 1px solid var(--line); - display: grid; - place-items: center; - padding: 0.5rem; -} - -@media (min-width: 48rem) { - .plate { - width: 11rem; - } -} - -.hero h1 { - font-size: clamp(2.4rem, 1.6rem + 4vw, 4rem); - letter-spacing: -0.02em; -} - -.hero .tagline { - font-size: clamp(1.15rem, 1rem + 0.8vw, 1.45rem); - margin-top: 1rem; - max-width: 34ch; - text-wrap: balance; -} - -.hero .sub { - color: var(--muted); - margin-top: 0.75rem; - max-width: var(--measure); -} - -/* What changes */ - -.grid-4 { - list-style: none; - margin: 2.5rem 0 0; - padding: 0; - display: grid; - gap: 1.25rem; - grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); -} - -.card { - background: var(--surface); +.hero { position: relative; padding: clamp(48px, 4rem + 3vw, 120px) 0 clamp(56px, 4rem + 2vw, 104px); overflow: hidden; } +.hero::before { + content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none; + background: + radial-gradient(60% 60% at 85% 20%, color-mix(in srgb, var(--accent) 16%, transparent), transparent 70%), + radial-gradient(50% 50% at 10% 90%, color-mix(in srgb, var(--mesh) 12%, transparent), transparent 70%); +} +.hero-grid { display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr); gap: clamp(2rem, 4vw, 4rem); align-items: center; } +@media (max-width: 960px) { .hero-grid { grid-template-columns: minmax(0, 1fr); } } +.hero h1 { margin-bottom: 0.4em; } +.hero h1 .accent { color: var(--accent-text); } +.hero .promise { font-size: clamp(1.15rem, 1rem + 0.6vw, 1.45rem); font-weight: 600; margin-bottom: 0.9rem; } +.hero .sub { color: var(--muted); max-width: 60ch; } +.hero .chips { margin: 1.5rem 0 2rem; } +.actions { display: flex; flex-wrap: wrap; gap: 0.75rem; } +.status-pill { + display: inline-flex; align-items: center; gap: 0.5rem; margin-bottom: 1.5rem; + padding: 0.35rem 0.85rem 0.35rem 0.4rem; border-radius: 999px; font-size: 0.87rem; font-weight: 600; + background: var(--surface); border: 1px solid var(--line); color: var(--muted); text-decoration: none; +} +.status-pill strong { background: var(--warn-soft); color: var(--warn); padding: 0.1rem 0.55rem; border-radius: 999px; font-size: 0.78rem; } +.status-pill:hover { border-color: var(--line-strong); color: var(--fg); } + +.mesh-panel { + position: relative; border-radius: 20px; border: 1px solid var(--line); + background: linear-gradient(180deg, var(--surface), var(--surface-2)); + box-shadow: var(--shadow-lg); overflow: hidden; +} +.panel-bar { + display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; + padding: 0.7rem 1rem; border-bottom: 1px solid var(--line); + font: 600 0.8rem/1.2 var(--mono); color: var(--faint); +} +.panel-bar .dots { display: inline-flex; gap: 6px; } +.panel-bar .dots i { width: 10px; height: 10px; border-radius: 50%; background: var(--line-strong); } +.mesh-stage { position: relative; aspect-ratio: 5 / 4; } +.mesh-stage canvas { width: 100%; height: 100%; } +.mesh-legend { + display: flex; flex-wrap: wrap; gap: 0.4rem 1rem; padding: 0.75rem 1rem; + border-top: 1px solid var(--line); font-size: 0.8rem; color: var(--faint); list-style: none; margin: 0; +} +.mesh-legend li { display: inline-flex; align-items: center; gap: 0.4rem; } +.mesh-legend i { width: 18px; height: 0; border-top: 2px solid var(--mesh); } +.mesh-legend i.nat { border-top-style: dashed; border-color: var(--accent); } +.mesh-legend i.dot { width: 8px; height: 8px; border: 0; border-radius: 50%; background: var(--accent); } + +/* Page hero for inner pages */ + +.page-hero { padding: clamp(48px, 3rem + 3vw, 96px) 0 clamp(32px, 2rem + 2vw, 64px); position: relative; overflow: hidden; } +.page-hero::before { + content: ""; position: absolute; inset: 0; z-index: -1; + background: radial-gradient(50% 80% at 90% 0%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 70%); +} +.page-hero h1 { font-size: clamp(2rem, 1.4rem + 2.6vw, 3.4rem); max-width: 20ch; } +.crumbs { font-size: 0.9rem; color: var(--faint); margin-bottom: 1rem; } +.crumbs a { color: var(--faint); } + +/* Layout with an in-page contents column */ + +.doc { display: grid; grid-template-columns: 230px minmax(0, 1fr); gap: clamp(2rem, 4vw, 4rem); align-items: start; } +@media (max-width: 960px) { .doc { grid-template-columns: minmax(0, 1fr); } } +.toc { position: sticky; top: calc(var(--header-h) + 24px); font-size: 0.92rem; } +.toc h2 { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.12em; color: var(--faint); margin-bottom: 0.75rem; } +.toc ol { list-style: none; padding: 0; margin: 0; border-left: 2px solid var(--line); } +.toc a { display: block; padding: 0.3rem 0 0.3rem 1rem; margin-left: -2px; border-left: 2px solid transparent; color: var(--muted); text-decoration: none; } +.toc a:hover { color: var(--fg); } +.toc a.is-active { color: var(--accent-text); border-left-color: var(--accent); font-weight: 600; } +@media (max-width: 960px) { + .toc { position: static; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 1rem 1.25rem; } +} + +.prose { min-width: 0; } +.prose > section { padding-bottom: clamp(2.5rem, 2rem + 2vw, 4rem); margin-bottom: clamp(2.5rem, 2rem + 2vw, 4rem); border-bottom: 1px solid var(--line); } +.prose > section:last-child { border-bottom: 0; margin-bottom: 0; } +.prose h2 { font-size: clamp(1.5rem, 1.25rem + 1vw, 2.1rem); } +.prose h3 { margin-top: 1.75rem; } +.prose p, .prose li { max-width: var(--measure); } +.prose li { margin-bottom: 0.4em; } +.prose .grid { margin: 1.5rem 0; } + +.callout { + border: 1px solid var(--line); border-left: 4px solid var(--accent); background: var(--surface); + border-radius: var(--radius-sm); padding: 1rem 1.25rem; margin: 1.5rem 0; max-width: 72ch; +} +.callout.warn { border-left-color: var(--warn); } +.callout p:last-child { margin-bottom: 0; } +.callout strong:first-child { display: block; margin-bottom: 0.25rem; } + +/* Tables */ + +.table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface); margin: 1.25rem 0; } +table { border-collapse: collapse; width: 100%; font-size: 0.95rem; } +th, td { text-align: left; padding: 0.7rem 0.9rem; border-bottom: 1px solid var(--line); vertical-align: top; } +thead th { background: var(--surface-2); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); white-space: nowrap; } +tbody tr:last-child td, tbody tr:last-child th { border-bottom: 0; } +tbody th { font-weight: 650; } +.state { font: 600 0.82rem var(--mono); padding: 0.1rem 0.45rem; border-radius: 6px; white-space: nowrap; } +.state-on { background: var(--mesh-soft); color: var(--mesh); } +.state-off { background: var(--surface-2); color: var(--muted); } + +/* Code blocks */ + +pre.code { + background: var(--term-bg); color: var(--term-fg); border-radius: var(--radius-sm); + padding: 1rem 1.2rem; overflow-x: auto; font-size: 0.85rem; line-height: 1.6; margin: 1.25rem 0; border: 1px solid var(--line); - border-radius: var(--radius); - padding: 1.5rem; - display: flex; - flex-direction: column; - gap: 0.6rem; -} - -.card .index { - font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, - "Liberation Mono", monospace; - font-size: 0.8rem; - color: var(--accent-text); - letter-spacing: 0.08em; -} - -.card h3 { - font-size: 1.15rem; -} - -.card p { - color: var(--muted); - font-size: 0.97rem; -} - -/* How it is built */ - -.build { - display: grid; - gap: 2.5rem; - margin-top: 2.5rem; - align-items: start; -} - -@media (min-width: 56rem) { - .build { - grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); - gap: 3.5rem; - } } - -.diagram { - margin: 0; -} - -.diagram svg { - width: 100%; - max-width: 36rem; - height: auto; - font-size: 15px; -} - -.diagram .mono { - font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, - "Liberation Mono", monospace; - font-size: 12.5px; -} - -.diagram .label { - font-weight: 700; -} - -.diagram .note { - font-size: 12px; - opacity: 0.75; -} - -.diagram figcaption { - color: var(--muted); - font-size: 0.95rem; - max-width: var(--measure); - margin-top: 1.25rem; -} - -.measured h3 { - font-size: 1.1rem; -} - -.measured .hint { - color: var(--muted); - font-size: 0.92rem; - margin-top: 0.35rem; -} - -.measured dl { - margin: 1.25rem 0 0; - border-top: 1px solid var(--line); -} - -.measured .row { - display: flex; - justify-content: space-between; - align-items: baseline; - gap: 1rem; - padding: 0.85rem 0; - border-bottom: 1px solid var(--line); -} - -.measured dt { - color: var(--muted); - font-size: 0.95rem; -} - -.measured dd { - margin: 0; - font-weight: 700; - font-variant-numeric: tabular-nums; - white-space: nowrap; -} - -.measured dd small { - font-weight: 400; - color: var(--muted); - font-size: 0.85em; +pre.code .c { color: #8FA3BA; } +pre.code .k { color: #7DC0F5; } +pre.code .s { color: #7EE0C3; } + +/* Composition diagram */ + +.compose { + display: grid; grid-template-columns: minmax(0, 1.25fr) 120px minmax(0, 1fr); + gap: 0; align-items: stretch; margin-top: 1rem; +} +.stack, .services { display: flex; flex-direction: column; gap: 0.6rem; list-style: none; margin: 0; padding: 0; } +.stack-title, .services-title { font-size: 0.78rem; font-weight: 750; letter-spacing: 0.12em; text-transform: uppercase; color: var(--faint); margin: 0 0 0.75rem; } +.layer { + position: relative; border-radius: 12px; padding: 0.9rem 1.1rem; border: 1px solid var(--line); + background: var(--surface); box-shadow: var(--shadow); +} +.layer strong { display: block; font-size: 1.02rem; } +.layer span { display: block; font-size: 0.88rem; color: var(--muted); } +.layer em { font-style: normal; font-family: var(--mono); font-size: 0.78rem; color: var(--faint); } +.layer-app { margin: 0 12%; border-top: 3px solid #7B61FF; } +.layer-runtime { margin: 0 8%; border-top: 3px solid var(--mesh); } +.layer-web { margin: 0 4%; border-top: 3px solid var(--accent); } +.layer-core { border-top: 3px solid var(--fg); background: var(--surface-2); } +.service { border-left: 3px solid var(--accent); } +.mesh-link { position: relative; display: grid; place-items: center; } +.mesh-link::before { + content: ""; position: absolute; left: 12px; right: 12px; top: 50%; height: 2px; + background: repeating-linear-gradient(90deg, var(--mesh) 0 8px, transparent 8px 14px); +} +.mesh-link span { + position: relative; background: var(--mesh-soft); color: var(--mesh); border: 1px solid var(--mesh); + font: 700 0.72rem/1.25 var(--mono); padding: 0.35rem 0.5rem; border-radius: 8px; text-align: center; +} +.mesh-link .flow { + position: absolute; top: calc(50% - 4px); left: 12px; width: 8px; height: 8px; border-radius: 50%; + background: var(--mesh); box-shadow: 0 0 0 4px color-mix(in srgb, var(--mesh) 25%, transparent); } +@media (prefers-reduced-motion: no-preference) { + .mesh-link .flow { animation: flow 2.6s linear infinite; } + .mesh-link .flow.two { animation-delay: 1.3s; animation-direction: reverse; } +} +@keyframes flow { from { left: 12px; } to { left: calc(100% - 20px); } } +.compose-note { margin-top: 1.25rem; font-size: 0.92rem; color: var(--faint); max-width: 80ch; } +@media (max-width: 760px) { + .compose { grid-template-columns: minmax(0, 1fr); } + .mesh-link { height: 72px; } + .mesh-link::before { left: 50%; right: auto; top: 6px; bottom: 6px; width: 2px; height: auto; + background: repeating-linear-gradient(180deg, var(--mesh) 0 8px, transparent 8px 14px); } + .mesh-link .flow { display: none; } + .layer-app, .layer-runtime, .layer-web { margin: 0; } +} + +/* Installation modes */ + +.mode .nodes { display: flex; gap: 0.5rem; margin: 0.25rem 0 1rem; flex-wrap: wrap; } +.mode .node { + display: grid; place-items: center; min-width: 58px; height: 40px; padding: 0 0.5rem; border-radius: 8px; + font: 700 0.72rem var(--mono); background: var(--surface-2); border: 1px solid var(--line-strong); color: var(--muted); +} +.mode .node.primary { border-color: var(--accent); color: var(--accent-text); background: var(--accent-soft); } +.mode .node.voter { border-style: dashed; } +.mode ul { color: var(--muted); font-size: 0.95rem; margin-bottom: 0; } + +/* Request pipeline */ + +.pipeline { background: var(--surface); border: 1px solid var(--line); border-radius: 20px; box-shadow: var(--shadow); overflow: hidden; } +.pipeline-stage { position: relative; } +.pipeline-stage canvas { width: 100%; height: 150px; } +.pipeline-steps { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); counter-reset: step; } +.pipeline-steps li { padding: 1.1rem 1rem; border-right: 1px solid var(--line); font-size: 0.9rem; color: var(--muted); counter-increment: step; } +.pipeline-steps li:last-child { border-right: 0; } +.pipeline-steps strong { display: block; color: var(--fg); font-size: 0.98rem; margin-bottom: 0.2rem; } +.pipeline-steps strong::before { content: counter(step) ". "; color: var(--accent-text); } +.pipeline-foot { display: flex; flex-wrap: wrap; gap: 0.5rem 1.25rem; padding: 0.75rem 1rem; border-top: 1px solid var(--line); font-size: 0.82rem; color: var(--faint); } +.pipeline-foot span { display: inline-flex; align-items: center; gap: 0.4rem; } +.pipeline-foot i { width: 9px; height: 9px; border-radius: 50%; display: inline-block; } +.pipeline-foot .ok { background: var(--mesh); } +.pipeline-foot .ban { background: var(--stop); } +.pipeline-foot .waf { background: var(--warn); } +.pipeline-foot .pow { background: var(--accent); } +@media (max-width: 900px) { + .pipeline-steps { grid-template-columns: minmax(0, 1fr); } + .pipeline-steps li { border-right: 0; border-bottom: 1px solid var(--line); } + .pipeline-steps li:last-child { border-bottom: 0; } +} + +/* Terminal */ + +.terminal { border-radius: 16px; background: var(--term-bg); color: var(--term-fg); box-shadow: var(--shadow-lg); overflow: hidden; border: 1px solid #1C3354; } +.terminal .panel-bar { border-bottom-color: #1C3354; color: #8FA3BA; } +.terminal .panel-bar .dots i { background: #2A4468; } +.terminal pre { margin: 0; padding: 1.1rem 1.2rem 1.4rem; font-size: 0.8rem; line-height: 1.65; min-height: 31em; overflow-x: auto; white-space: pre-wrap; overflow-wrap: anywhere; } +.terminal .prompt { color: #7EE0C3; } +.terminal .cmd { color: #FFFFFF; font-weight: 600; } +.terminal .same { color: #8FA3BA; } +.terminal .drift { color: #F2C36B; } +.terminal .done { color: #7EE0C3; } +.terminal .note { color: #7D93AD; font-style: italic; } +.terminal .caret { display: inline-block; width: 0.6em; height: 1.1em; vertical-align: text-bottom; background: #7EE0C3; } +@media (prefers-reduced-motion: no-preference) { .terminal .caret { animation: blink 1s steps(1) infinite; } } +@keyframes blink { 50% { opacity: 0; } } +.terminal-tools { display: flex; justify-content: flex-end; } +.terminal-tools button { + background: transparent; border: 1px solid #2A4468; color: #B8C7D8; border-radius: 6px; + font: 600 0.75rem var(--mono); padding: 0.2rem 0.6rem; cursor: pointer; +} +.terminal-tools button:hover { color: #FFFFFF; border-color: #7DC0F5; } + +/* Split feature rows */ + +.split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr); gap: clamp(2rem, 4vw, 4rem); align-items: center; } +.split.reverse > :first-child { order: 2; } +@media (max-width: 960px) { .split { grid-template-columns: minmax(0, 1fr); } .split.reverse > :first-child { order: 0; } } +.checks { list-style: none; padding: 0; margin: 1.25rem 0 0; } +.checks li { position: relative; padding-left: 1.9rem; margin-bottom: 0.75rem; color: var(--muted); } +.checks li strong { color: var(--fg); } +.checks li::before { + content: ""; position: absolute; left: 0; top: 0.3em; width: 1.1rem; height: 1.1rem; border-radius: 50%; + background: var(--mesh-soft) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4 8.5l2.5 2.5L12 5.5' fill='none' stroke='%230A6B66' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / 80% no-repeat; +} + +/* Layers and measurements */ + +.manifest-card { font: 0.84rem/1.8 var(--mono); } +.manifest-card .row { display: flex; justify-content: space-between; gap: 1rem; border-bottom: 1px dashed var(--line); padding: 0.2rem 0; } +.manifest-card .row:last-child { border-bottom: 0; } +.manifest-card .sig { color: var(--mesh); font-weight: 700; } +.bars { list-style: none; margin: 0; padding: 0; } +.bars li { margin-bottom: 1.1rem; } +.bars .label { margin: 0 0 0.35rem; display: flex; justify-content: space-between; gap: 1rem; font-size: 0.93rem; margin-bottom: 0.35rem; } +.bars .label span:last-child { font-weight: 700; font-variant-numeric: tabular-nums; } +.bars .track { display: block; height: 10px; border-radius: 999px; background: var(--surface-2); border: 1px solid var(--line); overflow: hidden; } +.bars .fill { display: block; height: 100%; width: var(--w); border-radius: 999px; background: linear-gradient(90deg, var(--accent), var(--mesh)); } +.bars .fill.slow { background: var(--line-strong); } +.js-motion .bars .fill { width: 0; transition: width 1.1s cubic-bezier(.2, .7, .2, 1); } +.js-motion .is-in .bars .fill, .js-motion .bars.is-in .fill { width: var(--w); } +.hint { font-size: 0.88rem; color: var(--faint); } /* Standards */ -.standards { - list-style: none; - margin: 2.5rem 0 0; - padding: 0; - display: grid; - gap: 1.5rem 2.5rem; -} - -@media (min-width: 48rem) { - .standards { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} - -.standards li { - position: relative; - padding-left: 1.5rem; -} - -.standards li::before { - content: ""; - position: absolute; - left: 0; - top: 0.55rem; - width: 0.7rem; - height: 0.7rem; - background: var(--accent); - border-radius: 2px; -} - -.standards strong { - display: block; - font-size: 1.05rem; -} - -.standards p { - color: var(--muted); - font-size: 0.97rem; - margin-top: 0.25rem; -} - -/* Status */ - -.status .text { - max-width: var(--measure); - margin-top: 1.5rem; -} - -.status h3 { - font-size: 1.1rem; - margin-top: 2.5rem; -} - -.next { - margin: 1rem 0 0; - padding-left: 1.4rem; - max-width: var(--measure); - display: grid; - gap: 0.6rem; -} - -.next li::marker { - color: var(--accent-text); - font-weight: 700; -} +.standards { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); gap: 1.25rem; list-style: none; margin: 0; padding: 0; } +.standards li { border-top: 2px solid var(--fg); padding-top: 1rem; } +.standards strong { display: block; font-size: 1.05rem; margin-bottom: 0.35rem; } +.standards p { color: var(--muted); margin: 0; } + +/* Status and roadmap */ + +.status-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: clamp(2rem, 4vw, 4rem); } +@media (max-width: 860px) { .status-grid { grid-template-columns: minmax(0, 1fr); } } +.focus-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 0.75rem; } +.focus-list li { display: flex; gap: 1rem; align-items: flex-start; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 1rem 1.1rem; } +.focus-list .num { flex: none; font: 700 0.8rem var(--mono); color: var(--accent-text); background: var(--accent-soft); border-radius: 6px; padding: 0.2rem 0.45rem; } +.focus-list p { margin: 0; color: var(--muted); font-size: 0.95rem; } +.focus-list strong { display: block; color: var(--fg); } + +.phases { list-style: none; margin: 0; padding: 0; position: relative; } +.phases::before { content: ""; position: absolute; left: 19px; top: 8px; bottom: 8px; width: 2px; background: var(--line); } +.phase { position: relative; padding-left: 60px; margin-bottom: 1.5rem; } +.phase-dot { + position: absolute; left: 0; top: 1.4rem; width: 40px; height: 40px; border-radius: 50%; + display: grid; place-items: center; font: 700 0.85rem var(--mono); + background: var(--surface); border: 2px solid var(--line-strong); color: var(--muted); +} +.phase.is-current .phase-dot { border-color: var(--accent); color: var(--accent-text); background: var(--accent-soft); } +.phase .card { padding: 1.4rem 1.5rem; } +.phase h2 { font-size: 1.35rem; margin: 0; } +.phase-head { display: flex; justify-content: space-between; align-items: center; gap: 0.75rem; flex-wrap: wrap; margin-bottom: 0.75rem; } +.progress { display: flex; align-items: center; gap: 0.6rem; font-size: 0.82rem; color: var(--faint); font-variant-numeric: tabular-nums; } +.progress .track { display: block; width: 120px; height: 8px; border-radius: 999px; background: var(--surface-2); border: 1px solid var(--line); overflow: hidden; } +.progress .fill { display: block; height: 100%; width: var(--w); background: var(--mesh); } +.tasks { list-style: none; padding: 0; margin: 0 0 1rem; } +.tasks li { position: relative; padding-left: 1.8rem; margin-bottom: 0.45rem; color: var(--muted); font-size: 0.97rem; } +.tasks li::before { + content: ""; position: absolute; left: 0; top: 0.28em; width: 1.05rem; height: 1.05rem; border-radius: 4px; + border: 2px solid var(--line-strong); background: var(--surface); +} +.tasks li.done { color: var(--fg); } +.tasks li.done::before { + border-color: var(--mesh); background: var(--mesh) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4 8.5l2.5 2.5L12 5.5' fill='none' stroke='white' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / 90% no-repeat; +} +.done-when { font-size: 0.93rem; background: var(--surface-2); border-radius: var(--radius-sm); padding: 0.75rem 1rem; color: var(--muted); } +.done-when strong { color: var(--fg); } +.filters { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1.5rem; } +.filters button { + font: 600 0.88rem/1 system-ui, sans-serif; padding: 0.55rem 0.9rem; border-radius: 999px; cursor: pointer; + border: 1px solid var(--line-strong); background: var(--surface); color: var(--muted); +} +.filters button[aria-pressed="true"] { background: var(--btn-bg); color: var(--btn-fg); border-color: var(--btn-bg); } +.tasks.only-open li.done, .tasks.only-done li:not(.done) { display: none; } + +/* CTA band */ + +.cta { border-radius: 24px; padding: clamp(2rem, 1.5rem + 3vw, 4rem); background: linear-gradient(135deg, var(--navy), #123A66); color: #EEF3F8; position: relative; overflow: hidden; } +.cta h2 { color: #FFFFFF; max-width: 22ch; } +.cta p { color: #C6D4E4; max-width: 60ch; } +.cta .btn-primary { background: #FFFFFF; color: var(--navy); } +.cta .btn-ghost { background: transparent; color: #FFFFFF; border-color: rgb(255 255 255 / 40%); } +.cta a:not(.btn) { color: #9DD0F7; } + +/* Guidelines */ + +.guide h2 { margin-top: 2.5rem; font-size: 1.5rem; } +.guide h2:first-child { margin-top: 0; } +.swatches { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); gap: 1rem; list-style: none; padding: 0; margin: 1rem 0 1.5rem; } +.swatches li { border: 1px solid var(--line); border-radius: var(--radius-sm); overflow: hidden; background: var(--surface); } +.swatches .chip-color { display: block; height: 64px; background: var(--c); } +.swatches p { margin: 0; padding: 0.6rem 0.8rem; font-size: 0.88rem; } +.swatches code { background: none; padding: 0; } +.mark-row { display: flex; flex-wrap: wrap; gap: 1rem; margin: 1rem 0; } +.mark-row figure { margin: 0; background: var(--plate); border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 1rem; } +.mark-row figure.dark { background: var(--navy); } +.mark-row img { width: 96px; height: 96px; } /* Footer */ -.site-footer { - border-top: 1px solid var(--line); - padding-block: 2.5rem; - color: var(--muted); - font-size: 0.92rem; -} - -.site-footer .wrap { - display: grid; - gap: 1rem; -} - -.site-footer p { - max-width: var(--measure); -} - -/* Secondary pages */ - -.page { - border-top: 0; - padding-block: 3.5rem 4.5rem; -} - -.page h1 { - font-size: clamp(1.9rem, 1.3rem + 2.4vw, 2.75rem); - letter-spacing: -0.02em; -} - -.guide { - max-width: var(--measure); - margin-top: 2.5rem; - display: grid; - gap: 1.75rem; -} - -.guide h2 { - font-size: 1.1rem; - margin-bottom: 0.4rem; -} - -.guide ul { - margin: 0; - padding-left: 1.25rem; - display: grid; - gap: 0.35rem; -} - -.guide li::marker { - color: var(--accent); -} - -/* Architecture */ - -.scenarios { - list-style: none; - margin: 2rem 0 0; - padding: 0; - display: grid; - gap: 0.6rem; - counter-reset: scenario; -} - -.scenarios li { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.4rem 0.75rem; - counter-increment: scenario; -} - -.scenarios li::before { - content: counter(scenario); - font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, - "Liberation Mono", monospace; - font-size: 0.85rem; - color: var(--accent-text); - min-width: 1.2em; -} - -.scenarios a { - font-weight: 600; -} - -.badge { - display: inline-block; - padding: 0.2rem 0.7rem; - border-radius: 999px; - font-size: 0.78rem; - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; - line-height: 1.4; - white-space: nowrap; - border: 2px solid transparent; -} - -.badge-goal { - background: var(--accent); - color: var(--navy); -} - -.badge-planned { - border-color: var(--accent); - color: var(--accent-text); -} - -.badge-future { - border: 2px dashed var(--muted); - color: var(--muted); -} - -.badge-soon { - border: 1px dashed var(--accent); - color: var(--accent-text); - font-size: 0.7rem; - padding: 0.05rem 0.55rem; - margin-right: 0.25rem; - white-space: normal; -} - -.arch-note { - max-width: var(--measure); - margin-top: 1rem; - padding-left: 0.9rem; - border-left: 3px solid var(--accent); - font-weight: 600; -} - -.arch-head { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.6rem 1rem; -} - -.diagram-arch { - margin: 2.5rem 0 0; -} - -.diagram-arch svg { - width: 100%; - height: auto; - color: var(--fg); - font-size: 15px; -} - -.diagram-arch svg.wide { - display: none; - max-width: 55rem; -} - -.diagram-arch svg.narrow { - max-width: 30rem; -} - -@media (min-width: 56rem) { - .diagram-arch svg.wide { - display: block; - } - - .diagram-arch svg.narrow { - display: none; - } -} - -.diagram-arch text { - fill: currentColor; -} - -.diagram-arch text.t-b { - font-weight: 700; -} - -.diagram-arch text.t-s { - font-size: 13px; -} - -.diagram-arch text.t-xs { - font-size: 13px; -} - -.diagram-arch text.t-m { - font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, - "Liberation Mono", "DejaVu Sans Mono", monospace; - font-size: 13px; -} - -.diagram-arch text.t-cap { - font-size: 13px; - font-weight: 700; - letter-spacing: 0.08em; -} - -.diagram-arch text.f-muted { - fill: var(--muted); -} - -.diagram-arch text.f-accent { - fill: var(--accent-text); -} - -.diagram-arch text.badge-goal-text { - fill: var(--navy); -} - -.diagram-arch .badge-goal-shape { - fill: var(--accent); -} - -.diagram-arch .badge-planned-shape { - fill: none; - stroke: var(--accent); - stroke-width: 2; -} - -.diagram-arch .badge-future-shape { - fill: none; - stroke: var(--muted); - stroke-width: 2; - stroke-dasharray: 5 4; -} - -.diagram-arch .box { - fill: var(--surface); - stroke: currentColor; - stroke-width: 1.5; -} - -.diagram-arch .box-key { - stroke: var(--accent); - stroke-width: 3; -} - -.diagram-arch .box-inner { - fill: var(--bg); - stroke: var(--line); - stroke-width: 1.5; -} - -.diagram-arch .dashed { - stroke-dasharray: 7 5; -} - -.diagram-arch .tag { - fill: none; - stroke: var(--accent); - stroke-width: 1.5; - stroke-dasharray: 4 3; -} - -.diagram-arch .overlay { - fill: none; - stroke: var(--accent); - stroke-width: 2; - stroke-dasharray: 10 6; -} - -.diagram-arch .tier { - fill: var(--code-bg); - stroke: var(--line); - stroke-width: 1.5; -} - -.diagram-arch .flow { - fill: none; - stroke: currentColor; - stroke-width: 1.75; -} - -.diagram-arch .dashed-line { - stroke-dasharray: 5 4; -} - -.diagram-arch .rail { - stroke: var(--line); - stroke-width: 2; -} - -.diagram-arch .dot { - fill: var(--accent); -} +.site-footer { border-top: 1px solid var(--line); background: var(--bg-alt); padding: 3.5rem 0 2rem; font-size: 0.93rem; color: var(--muted); } +.footer-grid { display: grid; grid-template-columns: minmax(0, 1.6fr) repeat(2, minmax(0, 1fr)); gap: 2rem; } +@media (max-width: 760px) { .footer-grid { grid-template-columns: minmax(0, 1fr); } } +.site-footer h2 { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.12em; color: var(--faint); margin-bottom: 0.9rem; } +.site-footer ul { list-style: none; padding: 0; margin: 0; } +.site-footer li { margin-bottom: 0.45rem; } +.site-footer a { color: var(--muted); text-decoration: none; } +.site-footer a:hover { color: var(--fg); text-decoration: underline; } +.site-footer .brand { margin-bottom: 0.75rem; } +.legal { border-top: 1px solid var(--line); margin-top: 2.5rem; padding-top: 1.5rem; font-size: 0.85rem; color: var(--faint); } -.diagram-arch .dot-open { - fill: var(--surface); - stroke: var(--accent); - stroke-width: 2; -} - -.diagram-arch figcaption { - color: var(--muted); - font-size: 0.95rem; - max-width: var(--measure); - margin-top: 1.25rem; -} - -.facts { - margin: 2rem 0 0; - padding-left: 1.25rem; - max-width: var(--measure); - display: grid; - gap: 0.75rem; -} - -.facts li::marker { - color: var(--accent); -} - -.arch-sub { - font-size: 1.1rem; - margin-top: 2.5rem; -} +/* Scroll reveal: only when scripts run and motion is welcome */ -.arch .hint { - color: var(--muted); - font-size: 0.92rem; - margin-top: 0.5rem; - max-width: var(--measure); -} - -.table-scroll { - overflow-x: auto; - margin-top: 1rem; -} - -.choices { - border-collapse: collapse; - width: 100%; - max-width: 55rem; - font-size: 0.95rem; -} - -.choices th, -.choices td { - text-align: left; - vertical-align: top; - padding: 0.7rem 0.75rem 0.7rem 0; - border-bottom: 1px solid var(--line); -} +.js-motion [data-reveal] { opacity: 0; transform: translateY(18px); transition: opacity 0.7s ease, transform 0.7s cubic-bezier(.2, .7, .2, 1); } +.js-motion [data-reveal].is-in { opacity: 1; transform: none; } +.js-motion [data-reveal-delay="1"] { transition-delay: 0.08s; } +.js-motion [data-reveal-delay="2"] { transition-delay: 0.16s; } +.js-motion [data-reveal-delay="3"] { transition-delay: 0.24s; } -.choices thead th { - font-size: 0.8rem; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--muted); +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; } } -.choices tbody th { - font-weight: 600; +@media (max-width: 560px) { + :root { --gutter: 16px; } + .card { padding: 1.25rem; } + .btn { width: 100%; justify-content: center; } + .terminal pre { font-size: 0.7rem; } } -.choices td { - color: var(--muted); +@media print { + .site-header, .mesh-panel, .pipeline-stage, .terminal-tools { display: none; } } diff --git a/tests/test_sitecheck.py b/tests/test_sitecheck.py index d0346ca..7e1aaf4 100644 --- a/tests/test_sitecheck.py +++ b/tests/test_sitecheck.py @@ -17,7 +17,18 @@ import sitecheck # noqa: E402 -HEAD = 'Keel' +META = ( + '' + '' + '' + '' + '' + '' +) +HEAD = ( + 'Keel' + f"{META}" +) BODY = "

Keel

" @@ -205,6 +216,186 @@ def test_a_data_uri_is_not_a_reference(self): self.assertEqual(sitecheck.check({"index.html": text}, set()), []) +class TestRequiredMeta(unittest.TestCase): + def test_a_page_without_a_description_is_reported(self): + text = page(head=HEAD.replace('name="description"', 'name="x"')) + self.assertEqual( + sitecheck.check({"index.html": text}), + ["index.html: no description meta"], + ) + + def test_every_missing_social_tag_is_reported(self): + head = 'K' + findings = sitecheck.check({"index.html": page(head=head)}) + for key in sitecheck.REQUIRED_META: + self.assertIn(f"index.html: no {key} meta", findings) + + def test_an_empty_content_counts_as_missing(self): + text = page( + head=HEAD.replace( + '', + '', + ) + ) + self.assertEqual( + sitecheck.check({"index.html": text}), + ["index.html: no twitter:card meta"], + ) + + +class TestSiteOrigin(unittest.TestCase): + def test_a_link_on_the_site_origin_is_checked_as_internal(self): + text = page('x') + self.assertEqual( + sitecheck.check({"index.html": text}), + [ + "index.html: https://keellinux.org/gone.html" + " does not resolve to a file" + ], + ) + + def test_the_origin_itself_is_the_home_page(self): + text = page('') + self.assertEqual(sitecheck.check({"index.html": text}), []) + + def test_a_social_card_on_the_origin_counts_as_a_reference(self): + text = page( + '' + ) + self.assertEqual( + sitecheck.check({"index.html": text}, {"assets/card.png"}), [] + ) + + def test_a_fragment_on_the_origin_is_checked(self): + pages = { + "index.html": page('x'), + "two.html": page(), + } + self.assertEqual( + sitecheck.check(pages), + [ + "index.html: https://keellinux.org/two.html#no" + " names an id that is not there" + ], + ) + + +class TestExternalResources(unittest.TestCase): + def test_an_external_script_is_reported(self): + text = page('') + self.assertEqual( + sitecheck.check({"index.html": text}), + [ + "index.html: https://cdn.example.org/x.js" + " loads an external resource" + ], + ) + + def test_an_external_stylesheet_is_reported(self): + text = page( + '' + ) + self.assertEqual(len(sitecheck.check({"index.html": text})), 1) + + def test_an_external_image_is_reported(self): + text = page('') + self.assertEqual(len(sitecheck.check({"index.html": text})), 1) + + def test_a_lazily_loaded_module_counts_as_a_resource(self): + text = page('
') + self.assertEqual(len(sitecheck.check({"index.html": text})), 1) + + def test_a_lazily_loaded_module_is_an_asset_reference(self): + text = page('
') + self.assertEqual( + sitecheck.check({"index.html": text}, {"assets/js/m.js"}), [] + ) + + def test_the_analytics_script_is_allowed(self): + text = page( + '" + ) + self.assertEqual(sitecheck.check({"index.html": text}), []) + + def test_an_external_link_is_not_a_resource(self): + text = page( + 'x' + '' + ) + self.assertEqual(sitecheck.check({"index.html": text}), []) + + def test_an_external_url_in_a_stylesheet_is_reported(self): + styles = { + "style.css": "a{background:url('https://example.org/a.png')}" + "@import url(//fonts.example.org/b.css);" + } + self.assertEqual( + sitecheck.check_styles(styles), + [ + "style.css: https://example.org/a.png" + " loads an external resource", + "style.css: //fonts.example.org/b.css" + " loads an external resource", + ], + ) + + def test_a_local_url_in_a_stylesheet_passes(self): + styles = {"style.css": 'a{background:url("assets/a.svg")} b{x:url(data:,)}'} + self.assertEqual(sitecheck.check_styles(styles), []) + + def test_check_includes_the_stylesheets_it_is_given(self): + findings = sitecheck.check( + {"index.html": page()}, + styles={"style.css": "@import 'https://example.org/c.css';"}, + ) + self.assertEqual( + findings, + ["style.css: https://example.org/c.css loads an external resource"], + ) + + +class TestCopy(unittest.TestCase): + def test_a_forbidden_term_in_the_text_is_reported(self): + text = page("

See our Pricing page.

") + self.assertEqual( + sitecheck.check({"index.html": text}), + ["index.html: the copy names 'pricing'"], + ) + + def test_a_city_is_reported(self): + text = page("

A node in Lisbon.

") + self.assertEqual( + sitecheck.check({"index.html": text}), + ["index.html: the copy names 'lisbon'"], + ) + + def test_a_term_inside_a_longer_word_is_not_reported(self): + text = page("

Priceless and contractual are fine.

") + self.assertEqual(sitecheck.check({"index.html": text}), []) + + def test_a_forbidden_term_in_an_attribute_or_meta_is_reported(self): + text = page( + 'customer logo' + '' + ) + findings = sitecheck.check({"index.html": text}) + self.assertIn("index.html: the copy names 'customer'", findings) + self.assertIn("index.html: the copy names 'rudder'", findings) + + def test_scripts_and_styles_are_not_copy(self): + text = page("") + self.assertEqual(sitecheck.check({"index.html": text}), []) + + def test_an_em_dash_in_the_copy_is_reported(self): + text = page("

One file — the truth.

") + self.assertEqual( + sitecheck.check({"index.html": text}), + ["index.html: the copy has an em dash"], + ) + + class TestReadSite(unittest.TestCase): def test_it_reads_pages_and_assets_and_skips_the_rest(self): pages, assets = sitecheck.read_site(ROOT) @@ -237,6 +428,16 @@ class TestCommandLine(unittest.TestCase): def test_the_site_of_this_repository_has_no_findings(self): self.assertEqual(sitecheck.main(["sitecheck", ROOT]), 0) + def test_it_reads_the_stylesheets_of_the_tree(self): + import tempfile + + with tempfile.TemporaryDirectory() as tmp: + with open(join(tmp, "index.html"), "w") as fob: + fob.write(page('')) + with open(join(tmp, "style.css"), "w") as fob: + fob.write("@import 'https://example.org/c.css';") + self.assertEqual(sitecheck.main(["sitecheck", tmp]), 1) + def test_a_tree_with_no_page_is_a_usage_failure(self): self.assertEqual(sitecheck.main(["sitecheck", join(ROOT, "assets")]), 2) diff --git a/tools/sitecheck.py b/tools/sitecheck.py index 900a9a5..b3dfd9b 100644 --- a/tools/sitecheck.py +++ b/tools/sitecheck.py @@ -19,7 +19,14 @@ - an external link is https, because a page served over TLS that links over plain HTTP teaches the reader the wrong habit; - a page carries a title, a language and one h1, which is the least a - reader and a search engine can expect. + reader and a search engine can expect, and the description and social + tags a shared link is previewed from; + - a page loads nothing from another origin but the analytics script, and + neither does a stylesheet: the site is self-hosted, so a reader's + browser talks to one other party at most, and that one is named here; + - the copy names no commercial offer, customer or place, and has no em + dash: the site describes the architecture of the project and nothing + else, and the house style keeps to plain punctuation. Pure but for `read_site`: `check` is given a mapping of path to text and returns the findings, so every rule is tested without a filesystem. @@ -28,11 +35,53 @@ from __future__ import annotations import os +import re import sys from html.parser import HTMLParser -# Attributes that carry a reference to something else -REFERENCE_ATTRS = ("href", "src", "srcset", "poster") +# Attributes that carry a reference to something else. data-src is where a +# page names a script it loads only when the element comes into view. +REFERENCE_ATTRS = ("href", "src", "srcset", "poster", "data-src") +# The site's own origin: a reference to it is a reference into the tree, and +# is checked as one, so an absolute og:image or canonical URL cannot rot. +SITE_ORIGIN = "https://keellinux.org" +# Attributes whose reference the browser loads, whatever the tag +LOADING_ATTRS = ("src", "srcset", "poster", "data-src") +# rel values of a link element that make the browser load the target +LOADING_RELS = ( + "stylesheet", "icon", "apple-touch-icon", "mask-icon", "manifest", + "preload", "modulepreload", "prefetch", +) +# The only other origin a page may load from: the analytics script +ALLOWED_RESOURCE_PREFIXES = ("https://analytics.pop.coop/",) +# meta tags a page must carry with a non-empty content: the description a +# search engine shows and the tags a shared link is previewed from +REQUIRED_META = ( + "viewport", + "description", + "og:title", + "og:description", + "og:image", + "twitter:card", +) +# meta tags whose content is copy a reader sees, in a result or a preview +COPY_META_KEYS = ( + "description", "keywords", "og:title", "og:description", "og:site_name", + "og:image:alt", "twitter:title", "twitter:description", "twitter:image:alt", +) +# Attributes that are copy: read aloud, or shown on hover +COPY_ATTRS = ("alt", "title", "aria-label") +# What the site never names. It describes the architecture of Keel Linux: +# no commercial offer, no customer, no place a node runs in. Matched as whole +# words, case insensitively. +FORBIDDEN_TERMS = ( + "pricing", "price", "prices", "customer", "customers", "testimonial", + "testimonials", "contract", "contracts", "rudder", + "lisbon", "lisboa", "porto", "madrid", "são paulo", "sao paulo", + "curitiba", "florianópolis", "florianopolis", "rio de janeiro", + "brasília", "brasilia", +) +EM_DASH = "\u2014" # Extensions that are pages rather than assets PAGE_SUFFIX = ".html" # meta tags whose content is a reference, not text: the social card is @@ -69,9 +118,13 @@ def __init__(self) -> None: self.ids: set[str] = set() self.titles: list[str] = [] self.headings: list[str] = [] + self.resources: list[str] = [] + self.meta: dict[str, str] = {} + self.copy: list[str] = [] self.language = "" self._in_title = False self._in_h1 = False + self._hidden = 0 def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: values = {name: (value or "") for name, value in attrs} @@ -85,23 +138,49 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None self.headings.append("") if "id" in values and values["id"]: self.ids.add(values["id"]) + if tag in ("script", "style"): + self._hidden += 1 if tag == "meta": - key = values.get("property") or values.get("name") or "" - content = values.get("content", "").strip() - if key.lower() in META_REFERENCE_KEYS and content: - self.references.append(content) + self._handle_meta(values) for attr in REFERENCE_ATTRS: value = values.get(attr, "").strip() if value: self.references.append(value) + if self._loads(tag, attr, values): + self.resources.append(value) + self.copy.extend(values[attr] for attr in COPY_ATTRS if values.get(attr)) + + def _handle_meta(self, values: dict[str, str]) -> None: + key = (values.get("property") or values.get("name") or "").lower() + content = values.get("content", "").strip() + if key and content: + self.meta[key] = content + if key in META_REFERENCE_KEYS and content: + self.references.append(content) + if key in COPY_META_KEYS: + self.copy.append(content) + + @staticmethod + def _loads(tag: str, attr: str, values: dict[str, str]) -> bool: + """Whether the browser fetches this reference to render the page""" + if attr in LOADING_ATTRS: + return True + if tag == "link" and attr == "href": + rels = values.get("rel", "").lower().split() + return any(rel in LOADING_RELS for rel in rels) + return False def handle_endtag(self, tag: str) -> None: if tag == "title": self._in_title = False if tag == "h1": self._in_h1 = False + if tag in ("script", "style") and self._hidden: + self._hidden -= 1 def handle_data(self, data: str) -> None: + if not self._hidden: + self.copy.append(data) if self._in_title and self.titles: self.titles[-1] += data if self._in_h1 and self.headings: @@ -119,17 +198,35 @@ def is_external(reference: str) -> bool: return "//" in reference.split("?")[0] or reference.startswith("mailto:") +def on_site_origin(reference: str) -> bool: + return reference == SITE_ORIGIN or reference.startswith(SITE_ORIGIN + "/") + + +def from_site_origin(reference: str) -> str: + """A reference on the site's origin as a path from the site root""" + rest = reference[len(SITE_ORIGIN):] + target, sep, fragment = rest.partition("#") + if not target or target.endswith("/"): + target += "index.html" + return target + sep + fragment + + def split_fragment(reference: str) -> tuple[str, str]: """A reference as (path, fragment); either part may be empty""" path, _, fragment = reference.partition("#") return path, fragment -def check(pages: dict[str, str], assets: set[str] | None = None) -> list[str]: +def check( + pages: dict[str, str], + assets: set[str] | None = None, + styles: dict[str, str] | None = None, +) -> list[str]: """Findings for a site given as {path: text}, one line each `assets` is every non page file in the tree, used to find the ones no - page references. Leave it out to skip that rule. + page references. Leave it out to skip that rule. `styles` is the text of + each stylesheet, checked for what it loads from elsewhere. """ findings: list[str] = [] parsed = {path: parse(text) for path, text in sorted(pages.items())} @@ -137,13 +234,19 @@ def check(pages: dict[str, str], assets: set[str] | None = None) -> list[str]: for path, page in parsed.items(): findings.extend(_check_shape(path, page)) + findings.extend(_check_meta(path, page)) + findings.extend(_check_resources(path, page)) + findings.extend(_check_copy(path, page)) for reference in page.references: if reference.startswith("data:"): continue - if is_external(reference): + local = reference + if on_site_origin(reference): + local = from_site_origin(reference) + elif is_external(reference): findings.extend(_check_external(path, reference)) continue - target, fragment = split_fragment(reference) + target, fragment = split_fragment(local) if target: referenced.add(_resolve(path, target)) findings.extend( @@ -155,6 +258,59 @@ def check(pages: dict[str, str], assets: set[str] | None = None) -> list[str]: if assets is not None: for asset in sorted(assets - referenced): findings.append(f"{asset}: no page references it") + findings.extend(check_styles(styles or {})) + return findings + + +def _check_meta(path: str, page: Page) -> list[str]: + return [ + f"{path}: no {key} meta" for key in REQUIRED_META if key not in page.meta + ] + + +def _loads_elsewhere(reference: str) -> bool: + if reference.startswith("data:") or on_site_origin(reference): + return False + if not is_external(reference) or reference.startswith("mailto:"): + return False + return not reference.startswith(ALLOWED_RESOURCE_PREFIXES) + + +def _check_resources(path: str, page: Page) -> list[str]: + return [ + f"{path}: {reference} loads an external resource" + for reference in page.resources + if _loads_elsewhere(reference) + ] + + +def _check_copy(path: str, page: Page) -> list[str]: + text = " ".join(page.copy) + lowered = text.lower() + findings = [ + f"{path}: the copy names {term!r}" + for term in FORBIDDEN_TERMS + if re.search(r"(? list[str]: + """Findings for stylesheets given as {path: text}: what they load""" + findings = [] + for path, text in sorted(styles.items()): + for match in STYLE_REFERENCE.finditer(text): + reference = match.group(1) or match.group(2) + if _loads_elsewhere(reference): + findings.append(f"{path}: {reference} loads an external resource") return findings @@ -265,7 +421,12 @@ def main(argv: list[str]) -> int: if not pages: print(f"{root}: no pages found", file=sys.stderr) return 2 - findings = check(pages, assets) + styles = {} + for asset in sorted(assets): + if asset.endswith(".css"): + with open(os.path.join(root, asset), encoding="utf-8") as fob: + styles[asset] = fob.read() + findings = check(pages, assets, styles) for finding in findings: print(finding) print(