summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.envrc5
-rw-r--r--.gitignore5
-rw-r--r--Makefile25
-rw-r--r--default.nix31
-rwxr-xr-xgenerate-sitemap40
-rwxr-xr-xserve-http38
-rw-r--r--shell.nix23
-rw-r--r--src/about.thrust21
-rw-r--r--src/catastrophic-failure.thrust51
-rw-r--r--src/catbus-design.thrust183
-rw-r--r--src/catbus-logging.thrust46
-rw-r--r--src/catbus.thrust23
-rw-r--r--src/how-to-write-to-your-mp.thrust76
-rw-r--r--src/index.thrust28
-rw-r--r--src/multi-room-audio.thrust58
l---------src/nix.thrust1
-rw-r--r--src/nixos.thrust134
-rw-r--r--src/projects.thrust27
-rw-r--r--src/publications.thrust24
-rw-r--r--src/resume.thrust62
-rw-r--r--src/systemd-dynamicuser.thrust161
-rw-r--r--src/tailscale.thrust164
-rw-r--r--src/ve-day-propaganda.thrust71
-rw-r--r--src/wifi.thrust350
-rw-r--r--src/writings.thrust31
-rw-r--r--src/writings/aissist.thrust59
-rw-r--r--src/writings/bruges.thrust109
-rw-r--r--src/writings/chair.thrust72
-rw-r--r--src/writings/chesham.thrust74
-rw-r--r--src/writings/helena.thrust49
-rw-r--r--src/writings/piracy.thrust63
-rw-r--r--src/yubikey.thrust135
-rw-r--r--templates/base.html17
-rw-r--r--templates/css128
-rwxr-xr-xthrust133
35 files changed, 2517 insertions, 0 deletions
diff --git a/.envrc b/.envrc
new file mode 100644
index 0000000..6055a98
--- /dev/null
+++ b/.envrc
@@ -0,0 +1,5 @@
+# SPDX-FileCopyrightText: 2020 Ethel Morgan
+#
+# SPDX-License-Identifier: MIT
+
+use_nix
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..24e4d62
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+.DS_Store
+/result
+ENV
+_site
+build
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..7f642d6
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,25 @@
+.PHONY: all clean
+
+HTML_PAGES = $(patsubst src/%.html,build/%.html,$(shell find src -name '*.html'))
+THRUST_PAGES = $(patsubst src/%.thrust,build/%.html,$(shell find src -name '*.thrust'))
+
+PAGES = $(HTML_PAGES) $(THRUST_PAGES)
+
+all: build/sitemap.xml $(PAGES)
+
+build/sitemap.xml: $(PAGES)
+ ./generate-sitemap $^ > $@
+
+build/%.html: src/%.html
+ @mkdir -p $(@D)
+ cp $< $@
+
+build/%.html: src/%.thrust templates/* thrust
+ @mkdir -p $(@D)
+ ./thrust $< > $@
+
+clean:
+ rm -rf build
+
+dev:
+ find . | entr $(MAKE) -j
diff --git a/default.nix b/default.nix
new file mode 100644
index 0000000..cc47ed0
--- /dev/null
+++ b/default.nix
@@ -0,0 +1,31 @@
+{ pkgs ? import <nixpkgs> {} }:
+with pkgs;
+
+stdenv.mkDerivation rec {
+ name = "ethulhu-co-uk-${version}";
+ version = "latest";
+
+ src = ./.;
+
+ buildInputs = [
+ python38
+ python38Packages.jinja2
+ python38Packages.markdown
+ python38Packages.pyaml
+ ];
+
+ patchPhase = ''
+ patchShebangs thrust
+ patchShebangs generate-sitemap
+ '';
+
+ buildPhase = ''
+ make -j
+ '';
+
+ installPhase = ''
+ mkdir -p $out
+ cp -r ./build/* $out
+ '';
+
+}
diff --git a/generate-sitemap b/generate-sitemap
new file mode 100755
index 0000000..998b9ec
--- /dev/null
+++ b/generate-sitemap
@@ -0,0 +1,40 @@
+#!/usr/bin/env python3
+
+from os import path
+import textwrap
+import sys
+
+from typing import List
+
+import jinja2
+
+ROOT = 'https://ethulhu.co.uk'
+
+TEMPLATE = '''
+<?xml version='1.0' encoding='utf-8'?>
+<urlset xmlns='http://www.sitemaps.org/schemas/sitemap/0.9'
+ xmlns:xsi='http://www.w3.org/2001/XMLSchema-instance'
+ xsi:schemaLocation='http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd'>
+ {% for url in urls -%}
+ <url>
+ <loc>{{ root }}/{{ url.loc }}</loc>
+ </url>
+ {% endfor %}
+</urlset>
+'''.strip()
+
+
+def loc_from_path(p: str) -> str:
+ basename, _ = path.splitext(p)
+ return '/'.join(basename.split('/')[1:])
+
+
+if __name__ == '__main__':
+ paths = sys.argv[1:]
+
+ urls = [{'loc': loc_from_path(p)} for p in paths if p.endswith('.html')]
+
+ environment = jinja2.Environment(
+ trim_blocks=True,
+ )
+ print(environment.from_string(TEMPLATE).render(root=ROOT, urls=urls))
diff --git a/serve-http b/serve-http
new file mode 100755
index 0000000..6d69f05
--- /dev/null
+++ b/serve-http
@@ -0,0 +1,38 @@
+#!/usr/bin/env python3
+"""serve the website over HTTP."""
+
+from os import path
+import argparse
+import http.server
+import socketserver
+
+# https://docs.python.org/3/library/http.server.html
+
+
+class Handler(http.server.BaseHTTPRequestHandler):
+ def do_GET(self):
+ resource = f'build/{self.path}.html'
+ if self.path == '/':
+ resource = 'build/index.html'
+
+ if path.isfile(resource):
+ self.send_response(200)
+ self.send_header('Content-type', 'text/html')
+ self.end_headers()
+ with open(resource, mode='rb') as f:
+ self.wfile.write(f.read())
+ else:
+ self.send_response(404)
+ self.end_headers()
+ self.wfile.write(f'unknown path: {self.path}'.encode())
+
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--port', default=8080, type=int,
+ help='port to listen on.')
+ args = parser.parse_args()
+
+ with socketserver.TCPServer(('127.0.0.1', args.port), Handler) as httpd:
+ print('http://localhost:' + str(args.port))
+ httpd.serve_forever()
diff --git a/shell.nix b/shell.nix
new file mode 100644
index 0000000..960eead
--- /dev/null
+++ b/shell.nix
@@ -0,0 +1,23 @@
+# SPDX-FileCopyrightText: 2020 Ethel Morgan
+#
+# SPDX-License-Identifier: MIT
+
+{ pkgs ? import <nixpkgs> {} }:
+
+with pkgs; mkShell {
+ buildInputs = [
+ aspell
+ aspellDicts.en
+ aspellDicts.en-computers
+ aspellDicts.en-science
+
+ hunspell
+ hunspellDicts.en_US
+
+ entr
+
+ python38Packages.jinja2
+ python38Packages.markdown
+ python38Packages.pyaml
+ ];
+}
diff --git a/src/about.thrust b/src/about.thrust
new file mode 100644
index 0000000..17839f2
--- /dev/null
+++ b/src/about.thrust
@@ -0,0 +1,21 @@
+---
+title: about
+last-edited: 2019-11-29
+---
+{% extends 'templates/base.html' %}
+{% block body %}
+ <nav>
+ <a href='/'>&gt; index</a>
+ </nav>
+ <header>
+ <h1>{{ title }}</h1>
+ <header>
+
+ <p>for my professional services, please see <a href='https://runic-labs.co.uk/'>Runic Labs</a>.</p>
+
+ <h2>contact</h2>
+
+ <address>
+ email: <span class='non-breaking'>(λ xy . contactxethulhuycoyuk) @.</span>
+ </address>
+{% endblock %}
diff --git a/src/catastrophic-failure.thrust b/src/catastrophic-failure.thrust
new file mode 100644
index 0000000..e2a7709
--- /dev/null
+++ b/src/catastrophic-failure.thrust
@@ -0,0 +1,51 @@
+---
+title: Catastrophic Failure
+subtitle: your system is larger than you think
+date: 2020-07-01
+---
+{% extends 'templates/base.html' %}
+{% block body %}
+ <nav>
+ <a href='/'>&gt; index</a>
+ </nav>
+ <header>
+ <h1>{{ title }}</h1>
+ <p>{{ subtitle }}</p>
+ </header>
+ <article>
+{% markdown %}
+In a past life, I was a software verification researcher, and attended the [NASA Formal Methods](https://shemesh.larc.nasa.gov/NFM/) conference in 2014.
+The opening speaker at NFM gave a talk with various anecdotes about various kinds of failure, from "an implicit double-to-float cast wasted an entire mission" to _catastrophic failure_, in which someone dies.
+Catastrophic failure at NASA is big and obvious and makes the news: if the rocket goes wrong then everyone onboard dies in a big fireball a mile above the ground.
+
+They then moved on to work they had done for the US Postal Service, who had been experiencing thefts by counter staff, and wanted a new point-of-sale system to combat this.
+This involved finding a set of constraints for operating the cash register such that they could do their job while being unable to take anything extra.
+The speaker laid out the constraints, and offered a $100 Postal Order to the person who could find the Catastrophic Failure.
+
+> _audience:_ could it become locked out and inoperable?<br>
+> _speaker:_ no.<br>
+> _audience:_ could they take money by doing …?<br>
+> _speaker:_ no.<br>
+
+This went on for a while, as we tried to find deadlocks or vulnerabilities in the system.
+Eventually,
+
+> _audience:_ what if someone came in with a gun and demanded the money?<br>
+> _speaker:_ bingo.<br>
+
+USPS decided that stemming theft was not worth risking the death of an employee.
+
+What this story tells us is that software has consequences.
+It's easy to look at a missile guidance system or High Frequency Trading and say "that's unethical!", but far more mundane software performing far more mundane tasks can also have dangerous or even lethal failure modes.
+
+For example, banks are notoriously bad at updating names, and deadnames can resurface at inopportune moments that risk outing the user to housemates.
+Parental spyware will out a kid to their parents, risking homelessness or suicide.
+
+As engineers we must keep the _whole_ system in mind, including its users and their wider lives and situations.
+We must respond to our products' worst failure modes, no matter how unlikely we believe them to be.
+You cannot rollback a corpse.
+
+And if that means a product or feature does not launch, then so be it.
+{% endmarkdown %}
+ </article>
+{% endblock %}
diff --git a/src/catbus-design.thrust b/src/catbus-design.thrust
new file mode 100644
index 0000000..843d3b6
--- /dev/null
+++ b/src/catbus-design.thrust
@@ -0,0 +1,183 @@
+---
+title: 'CatBus: design'
+subtitle: a home automation platform built on MQTT
+date: 2020-02-20
+---
+{% extends 'templates/base.html' %}
+{% block body %}
+ <nav>
+ <a href='/catbus'>&gt; catbus home</a>
+ </nav>
+ <header>
+ <h1>{{ title }}</h1>
+ <p>{{ subtitle }}</p>
+ </header>
+ <article>
+{% markdown %}
+Home automation and the "Internet of Things" (IoT) is so hot right now.
+Between the phone vendors, washing machine makers, and light-bulb pushers, there are many home automation platforms.
+In this document, I will try to explain why I have made my own [15th standard](https://xkcd.com/927/).
+
+<details markdown='1'>
+<summary>contents…</summary>
+<div markdown='block'>
+
+[TOC]
+
+</div>
+</details>
+
+## Why not `$platform` ?
+
+I initially started by using Apple HomeKit via [HomeBridge](https://homebridge.io), which has a large collection of plugins for many of my devices.
+However, it was always limited to being controlled from Apple devices, and the automation abilities were limited.
+
+I briefly looked at [Home Assistant](https://www.home-assistant.io), which is open-source and can be controlled from Linux, but seemed brittle and inflexible.
+
+When I was gifted some hardware that lacked plugins for either HomeBridge or Home Assistant, requiring me to write software to use them, I decided I would invest that effort into building my own platform.
+
+## Wants & needs
+
+### Why do I want home automation?
+
+To lead a "better" life.
+
+### What do I mean by "better"?
+
+To lead a less stressful life:
+
+- Automate things I forget to do.
+- Turn down the lights as it gets late for better sleep.
+- Turn up the lights before the alarm goes off for an easier wake.
+- Detect when I'm at home, and remind me to eat.
+
+To lead a comfier life:
+
+- Convenience!
+- Actionable metrics, e.g. to predict whether or not I will feel cold in a given outfit given room temperatures.
+
+## Practical considerations
+
+### Low maintenance
+
+This is a hobby project, so it must be low-effort to maintain:
+
+- The system must be easy to add new parts to.
+- The system must allow existing parts to run without ongoing intervention.
+- The system should support multiple parts acting in concert without reconfiguration.
+
+### The real world comes first
+
+The system will not "own" the devices it controls: the TV's own remote must continue to work.
+
+- The system must not interfere with external control systems.
+- The system should reflect changes caused by external control systems.
+
+### Frictionless interaction
+
+When I used HomeKit, a frequent problem was that I had to use an Apple product to use it.
+If I had left my phone charging instead of in my pocket, I had to go and get it, which grew to be an annoyance.
+It should thus be controllable without needing a specific item:
+
+- The system must have a command-line interface.
+- The system must have a web interface.
+- The system should have a HomeKit interface.
+- The system should have physical controllers.
+
+## Models of operation
+
+Now that I have my requirements, there are a few high-level models of operation for managing the distributed state.
+
+First, a quick glossary:
+
+- A **user** is a human interacting with the system, either directly via the software or indirectly by being inside the house.
+- An **observer** is a part of the system that observes the state of something external to the system, such as the temperature of a room or the volume of a speaker.
+- An **actuator** is a part of the system that affects the state of something external to the system, such as the channel of a TV or whether a light is on.
+ In practice, many actuators are also observers.
+- An actuator has one or more **controls**, usually one for each thing that it affects, such as one for a TV's volume level and another for its channel.
+- A **controller** is a part of the system that tells the actuators what to do using their controls, such as a web UI or an automation daemon.
+
+### Remote objects
+
+_Remote objects_ (RPC, REST, SOAP, …) is a common idiom in distributed computing.
+A sender sends a message to recipient, which actions the message, possibly returning a response.
+The sender must know where to send the message, or talk to a central router that itself must know where to send the message.
+
+Under this system, to set up a new actuator for an existing control, I would need to add the actuator to the control's configuration.
+This is more wiring and configuration than I can be bothered with, and contravenes the [low maintenance requirement](#low-maintenance).
+
+### Intent-based
+
+In an _intent-based_ control system there is a central intended system state, and one or more actuators that try to make it reality.
+For example, if the intent says that the lights should be on, an actuator daemon continually checks the actual state of the lights, turning them on if they are off.
+
+This allows for low coupling, as the controller that sets the intent doesn't care which actuator turns on the light and how, which meets the [low maintenance requirement](#low-maintenance).
+However a naïve implementation risks violating the [real world requirement](#the-real-world-comes-first), e.g. if a light is turned off at the device itself, the actuator will repeatedly try to turn it back on.
+
+### The compromise: semi-intent-based
+
+The current implementation uses [MQTT](https://mqtt.org/) as a sort of real-time database.
+MQTT has "topics", and clients can publish events to topics and subscribe to events from topics.
+
+It meets the [low maintenance requirement](#low-maintenance):
+
+- Controllers & actuators only need to directly communicate with the MQTT Broker.
+- Controllers & actuators only need to know what topics they will subscribe or publish to.
+- Multiple actuators can listen on the same topic, which allows for "complex" devices:
+ - For example, "turn the speakers on" can trigger a relay device to physically turn the speakers on, and trigger another device to redirect its audio output.
+
+To meet the [real world requirement](#the-real-world-comes-first), actuators only act when a topic's value changes.
+The two rules for actuators are:
+
+- When a topic is updated, the actuator tries to make it true.
+- When an actuator notices an external change in the real-world state, it puts the new value on the bus.
+
+For example, with one actuator, one observer, and an external control:
+
+1. The TV is off.
+2. The topic `home/living-room/tv/power` is set to `on`.
+3. The actuator receives this change of state, and turns the TV on.
+4. After some time, the TV is turned off with the remote.
+5. The observer notices the TV is off, and sets the topic `home/living-room/tv/power` to `off`.
+6. The actuator receives this change of state, and tries to turn the TV off, which it already is.
+
+An example with two actuators:
+
+1. The speakers are off.
+2. The topic `home/living-room/speakers/power` is set to `on`.
+3. The first actuator receives this change of state, and turns the amp on.
+4. The second actuator receives this change of state, tries to turn the receiver on, and fails.
+5. The second actuator sets the topic `home/living-room/speakers/power` to `off`.
+6. The first actuator receives this change of state, and turns the amp back off.
+
+## Scheme
+
+MQTT is similar to a simple key-value store, with strings as keys and unstructured bytes as values.
+The keys can have hierarchy, like a filesystem path, but this isn't required.
+
+As it is stringly typed, I have opted for a key naming scheme:
+
+```
+home/{zone}/{device}/{control}(/{metadata})?
+```
+
+Similar to the [Prometheus best practices](https://prometheus.io/docs/practices/naming/), topics have `_suffixes` that state a topic's units and valid values.
+For example, a topic `home/kitchen/speakers/volume_percent` can only have number values in the range `[0,100]`.
+
+Since not all types of control are so convenient, topics can have optional metadata topics, e.g. a topic `…/foo_enum` can have a metadata topic `…/foo_enum/values` to list valid values for controllers to choose from.
+
+topic | data
+------------------------------------------------- | ----
+`home/living-room/speakers/power` | `on`
+`home/living-room/speakers/input_enum` | `UPnP`
+`home/living-room/speakers/input_enum/values` | `UPnP\nAirPlay`
+`home/outside/weather-sensor/temperature_celcius` | `15`
+
+The hierarchy also allows for controllers presenting a unified device to a user, even if it is actually multiple devices:
+
+- `speakers/power` is controlled by an Energenie RF relay socket.
+- `speakers/input_enum` is controlled by Snapcast.
+- `speakers/volume_percent` is controlled by ALSA.
+{% endmarkdown %}
+ </article>
+{% endblock %}
diff --git a/src/catbus-logging.thrust b/src/catbus-logging.thrust
new file mode 100644
index 0000000..bd41472
--- /dev/null
+++ b/src/catbus-logging.thrust
@@ -0,0 +1,46 @@
+---
+title: 'CatBus: logging'
+subtitle: easy low-configuration logging
+date: 2020-02-20
+---
+{% extends 'templates/base.html' %}
+{% block body %}
+ <nav>
+ <a href='/catbus'>&gt; catbus home</a>
+ </nav>
+ <header>
+ <h1>{{ title }}</h1>
+ <p>{{ subtitle }}</p>
+ </header>
+ <article>
+{% markdown %}
+Using the MQTT bus as a unified system with a unified semantic naming scheme means that I can also implement logging without the need for much configuration.
+
+Following the above scheme, we can automatically scrape all devices named `*-sensor` into Prometheus metrics:
+
+```
+$control{zone=$zone,device=$device} = $value
+```
+
+For example,
+
+```
+temperature_celsius{zone=bedroom,device=window-sensor} = 17
+```
+
+Likewise, we can export enums as:
+
+```
+home/zone/device/control_enum:
+ foo
+
+home/zone/device/control_enum/values:
+ bar
+ foo
+
+control{zone=,device=,value=bar} = 0
+control{zone=,device=,value=foo} = 1
+```
+{% endmarkdown %}
+ </article>
+{% endblock %}
diff --git a/src/catbus.thrust b/src/catbus.thrust
new file mode 100644
index 0000000..5147731
--- /dev/null
+++ b/src/catbus.thrust
@@ -0,0 +1,23 @@
+---
+title: CatBus
+subtitle: a home automation platform built on MQTT
+date: 2020-02-20
+---
+{% extends 'templates/base.html' %}
+{% block body %}
+ <nav>
+ <a href='/projects'>&gt; projects</a>
+ </nav>
+ <header>
+ <h1>{{ title }}</h1>
+ <p>{{ subtitle }}</p>
+ </header>
+{% markdown %}
+CatBus is a home automation platform designed to be low configuration, easily extendable without dependence on the internet, and suitable for automation by multiple independent daemons.
+
+- [design](/catbus-design)
+- actuators:
+ - [for Energenie relay sockets](https://github.com/ethulhu/mqtt-energenie-bridge)
+ - [for `usbrelay`-compatible USB relays](https://github.com/ethulhu/catbus-usbrelay-bridge)
+{% endmarkdown %}
+{% endblock %}
diff --git a/src/how-to-write-to-your-mp.thrust b/src/how-to-write-to-your-mp.thrust
new file mode 100644
index 0000000..a66968c
--- /dev/null
+++ b/src/how-to-write-to-your-mp.thrust
@@ -0,0 +1,76 @@
+---
+title: How to write to your MP
+date: 2020-02-20
+---
+{% extends 'templates/base.html' %}
+{% block body %}
+ <nav>
+ <a href='/'>&gt; index</a>
+ </nav>
+ <header>
+ <h1>{{ title }}</h1>
+ </header>
+{% markdown %}
+Below is a short guide on how to write to your elected representatives.
+The primary audience is people in the UK.
+
+## Step 1: Who is your MP?
+
+Go to [writetothem.com](https://writetothem.com), type in your postcode, and they'll be listed there at the bottom, along with any local councillors or London Assembly members or whatever, as relevant.
+Click on their name, and it will take you to a short form where you can type your strongly worded letter and they'll send it for you.
+
+Alternatively, look them up on the [House of Commons database](https://members.parliament.uk/members/Commons) and send them an email, or leave them a voicemail message.
+
+## Step 2: Who _is_ your MP?
+
+Your MP is not just your designated recipient of angry letters, they are a person.
+They have a voting record, they probably have a party affiliation, they may be on [Select Committees](https://www.parliament.uk/about/how/committees/select/), they may even be in the Cabinet or Shadow Cabinet.
+Maybe they've written articles for newspapers, run a business, or are a landlord.
+Maybe they are a member of a relevant targeted minority.
+Maybe they proposed the legislation you are writing about.
+
+How far you want to dig is up to you, but I would recommend at least knowing what party they are a member of.
+It helps you estimate what arguments may or may not work, and you can reference said party in your letter.
+I usually ask my MP to push for change not just in Parliament but also within the Labour Party.
+
+## Step 3: Why are you writing to them?
+
+Like any essay, draft your key points on a separate piece of paper.
+Then decide which of those key points will form the basis of your argument for or against the bill.
+
+For example, in response to the leaked proposed changes to trans rights in the UK in June 2020, my letter centered around the risk to trans youth and the specter of [Section 28](https://en.wikipedia.org/wiki/Section_28), while a friend centered their letter around "bathroom bills" and their tendency to hurt all women.
+
+Your MP is a person, and like most people, their time and attention span are limited, and they probably won't read an actual essay.
+
+## Step 4: Be snappy.
+
+> I am going to teach you how to do technical writing.
+>
+> First you tell them what you're going to tell them, then you tell them, then you tell them what you told them.
+>
+> I have just taught you how to do technical writing.
+
+Always have a [TL;DR](https://en.wiktionary.org/wiki/tl;dr#English) at the start, and a 1-sentence summary at the end.
+Again, your MP is a human person with a limited attention span, and they receive a lot of mail.
+
+Do not send them copypasta.
+Use your words, your _own_ words.
+If you wouldn't write it yourself, they probably won't read it.
+
+Likewise, get to the point.
+No _hithertofore_ or _imagine if you will_.
+Reference the bill, but a detailed legal analysis will come across as copypasta.
+
+## Step 5: Reread it.
+
+Step away for a few minutes, then reread it.
+Is there colorful language that can be toned down?
+Did you get a little incoherent in your righteous anger?
+
+Make it tighter, make it clearer, make it snappier.
+
+## Step 6: Send it.
+
+Fill out your details on the form, click `send`, wait, confirm to WriteToThem that this is the letter you want to send, then sit back and <s>relax</s> encourage your friends to write their own letters, maybe offering them yours as inspiration.
+{% endmarkdown %}
+{% endblock %}
diff --git a/src/index.thrust b/src/index.thrust
new file mode 100644
index 0000000..cb8093b
--- /dev/null
+++ b/src/index.thrust
@@ -0,0 +1,28 @@
+---
+last edited: 2019-11-29
+title: Eth Morgan
+subtitle: abyss domain expert
+---
+{% extends 'templates/base.html' %}
+{% block body %}
+ <header>
+ <h1>{{ title }}</h1>
+ <p>{{ subtitle }}</p>
+ </header>
+
+ <nav>
+ <ul>
+ <li><a href='/about'>About</a></li>
+ </ul>
+ <ul>
+ <li><a href='/projects'>Projects</a></li>
+ <li><a href='/writings'>Stories</a></li>
+ </ul>
+ <ul>
+ <li><a href='https://runic-labs.co.uk'>Consulting</a></li>
+ <li><a href='/resume'>Resume</a></li>
+ <li><a href='https://github.com/ethulhu'>Github</a></li>
+ <li><a href='/publications'>Publications</a></li>
+ </ul>
+ </nav>
+{% endblock %}
diff --git a/src/multi-room-audio.thrust b/src/multi-room-audio.thrust
new file mode 100644
index 0000000..078de3c
--- /dev/null
+++ b/src/multi-room-audio.thrust
@@ -0,0 +1,58 @@
+---
+title: multi-room audio
+subtitle: Audio, in rooms. Multiple rooms.
+date: 2020-04-08
+---
+{% extends 'templates/base.html' %}
+{% block body %}
+ <nav>
+ <a href='/projects'>&gt; projects</a>
+ </nav>
+
+ <header>
+ <h1>{{ title }}</h1>
+ <p>{{ subtitle }}</p>
+ </header>
+
+ <article>
+ {% markdown %}
+ I briefly tried using [PulseAudio over the network](https://wiki.archlinux.org/index.php/PulseAudio/Examples#PulseAudio_over_network), but decided to use [Snapcast](https://github.com/badaix/snapcast) instead.
+ Snapcast seems to be more tolerant of network issues, doesn't require installing PulseAudio on otherwise headless machines, and has pretty good synchronization between rooms.
+
+ A Snapcast installation is split into one _Snapserver_ and multiple _Snapclients_.
+ Audio comes in to the Snapserver from one or more sources, and is broadcast out to the Snapclients, with a minor delay for synchronization.
+
+ On the machine being the Snapserver, install the `snapserver` package from APT, and configure its audio sources.
+ This snippet configures two sources:
+
+ - For [MPD controlled by UPnP](https://www.lesbonscomptes.com/upmpdcli/), it
+ - reads from a named pipe at `/tmp/snapfifo`,
+ - and presents it in the Snapcast UI as `UPnP`.
+ - For [AirPlay](https://en.wikipedia.org/wiki/AirPlay), it
+ - starts the binary `/usr/bin/shairport-sync`,
+ - presents it in the UI as `AirPlay`,
+ - sets it to use 44.1Khz output instead of 48Khz,
+ - and logs `shairport-sync`'s stderr.
+ - `shairport-sync` itself is configured to output audio to its stdout.
+
+ ```sh
+ $ cat /etc/default/snapserver
+ SNAPSERVER_OPTS="--stream pipe:///tmp/snapfifo?name=UPnP --stream process:///usr/bin/shairport-sync?name=AirPlay&sampleformat=44100:16:2&log_stderr=true"
+ ```
+
+ On all the machines being Snapclients, install the `snapclient` package, and configure their human-readable names.
+ For example, for the Snapclient in my living room:
+
+ ```sh
+ $ cat /etc/default/snapcast
+ SNAPCLIENT_OPTS="--hostID 'Living Room'"
+ ```
+
+ ## Control
+
+ I primarily control Snapcast using a [CatBus](/catbus) daemon.
+
+ In addition, I have configured both `shairport-sync` and `upmpdcli` to switch Snapcast to their respective input when they start playing.
+ {% endmarkdown %}
+ </article>
+{% endblock %}
diff --git a/src/nix.thrust b/src/nix.thrust
new file mode 120000
index 0000000..96aa041
--- /dev/null
+++ b/src/nix.thrust
@@ -0,0 +1 @@
+nixos.thrust \ No newline at end of file
diff --git a/src/nixos.thrust b/src/nixos.thrust
new file mode 100644
index 0000000..9d669aa
--- /dev/null
+++ b/src/nixos.thrust
@@ -0,0 +1,134 @@
+---
+title: Nix & NixOS
+date: 2020-07-06
+---
+{% extends 'templates/base.html' %}
+{% block body %}
+ <nav>
+ <a href='/projects'>&gt; projects home</a>
+ </nav>
+ <header>
+ <h1>{{ title }}</h1>
+ <p>{{ subtitle }}</p>
+ </header>
+ <article>
+{% markdown %}
+This article contains tips & tricks for using [NixOS](https://nixos.org).
+
+[TOC]
+
+## Installing NixOS
+
+This guide lays out how to install NixOS with an encrypted root and swap disk.
+The resulting system will have 2 physical partitions:
+
+- `/boot`.
+- `/dev/mapper/cryptroot`, an encrypted partition with 2 virtual partitions:
+ - `/dev/vg/root`, mounted as `/`.
+ - `/dev/vg/swap`, used for swap.
+
+### Physical partitions
+
+First, determine if your machine supports UEFI (most modern PCs), or only has BIOS (e.g. my Thinkpad X201).
+
+If it has UEFI, the below snippet creates a 512MiB UEFI boot partition, which has to be FAT32, and uses the rest of the disk for our cryptroot.
+
+ $ parted /dev/sda
+ > mklabel gpt
+ > mkpart ESP fat32 1MiB 512MiB
+ > set 1 boot on
+ > mkpart primary 512MiB 100%
+ > quit
+
+For machines without UEFI, the procedure is similar, but needs to use BIOS and MBR formats.
+
+ $ parted /dev/sda
+ > mklabel msdos
+ > mkpart primary 1MiB 512MiB
+ > set 1 boot on
+ > mkpart primary 512MiB 100%
+ > quit
+
+To set up [LUKS](https://en.wikipedia.org/wiki/Linux_Unified_Key_Setup) disk encryption, first encrypt the second partition (`/dev/sda2`), then unlock it and map it at `/dev/mapper/cryptroot`:
+
+ $ cryptsetup luksFormat /dev/sda2
+ $ cryptsetup open /dev/sda2 cryptroot
+
+### Virtual Partitions
+
+To split our LUKS-encrypted partition into two virtual partitions, we will use [LVM](https://en.wikipedia.org/wiki/Logical_Volume_Manager_(Linux)) to create a _Physical Volume_ containing a _Volume Group_ containing 2 _Logical Volumes_.
+
+Create a Physical Volume (`pv`):
+
+ $ pvcreate /dev/mapper/cryptroot
+
+Create a Volume Group (`vg`) on that `pv`:
+
+ $ vgcreate vg /dev/mapper/cryptroot
+
+Create two Logical Volumes (`lv`) on that `vg`, one for swap and one for data:
+
+ $ lvcreate -L 8G -n swap vg
+ $ lvcreate -l '100%FREE' -n root vg
+
+### Formatting & Mounting
+
+For UEFI, format the boot partition as FAT32:
+
+ $ mkfs.fat -F 32 -n boot /dev/sda1
+
+For BIOS / MBR, format the boot partition as EXT4:
+
+ $ mkfs.ext4 -L boot /dev/sda1
+
+For both, format `/` and the swap partition:
+
+ $ mkfs.ext4 -L root /dev/vg/root
+ $ mkswap -L swap /dev/vd/swap
+
+Then mount them all:
+
+ $ mount /dev/vg/root /mnt
+ $ mkdir /mnt/boot
+ $ mount /dev/sda1 /mnt/boot
+ $ swapon /dev/vg/swap
+
+### Actually installing NixOS
+
+We will need to make sure that our encrypted physical partition is unencrypted early in boot, so that the virtual partitions can be mounted.
+
+Generate an initial `configuration.nix`:
+
+ $ nixos-generate-config --root /mnt
+
+The file `/mnt/etc/nixos/hardware-configuration.nix` should reference the UUIDs for the physical `/dev/sda1` and _logical_ `/dev/vg/root` and `/dev/vg/swap`.
+
+To mount the encrypted LVM partitions early in boot, we need to pass the UUID of the physical partition `/dev/sda2` to `initrd`.
+This can be found with `ls -l /dev/disk/by-uuid` and seeing which UUID is pointing to `/dev/sda2`:
+
+ $ ls -l /dev/disk/by-uuid
+ ... b2bacfda-0929-43ef-9f8f-c326c41d9126 -> ../../sda1
+ ... 4e906a20-63fc-4aec-93de-8502f24daa91 -> ../../sda2
+ ... 97650361-78bc-4026-962d-0774fc388d63 -> ../../dm-1
+ ... fa099f4f-38b8-4ebc-914c-86b11598128d -> ../../dm-2
+
+This value must then be
+
+ $ cat /mnt/etc/nixos/configuration.nix
+ ...
+ boot.loader.grub.device = "/dev/sda";
+ boot.initrd.luks.devices.root = {
+ device = "/dev/disk/by-uuid/4e906a20-63fc-4aec-93de-8502f24daa91";
+ preLVM = true; # we need to decrypt the physical partition before LVM mounts our logical partitions.
+ allowDiscards = true; # enables TRIM, optional.
+ };
+
+Continue NixOS installation, rebooting after:
+
+ $ nixos-install
+ $ reboot
+
+After rebooting, you will need to type your LUKS password, and it should boot normally from there on.
+{% endmarkdown %}
+ </article>
+{% endblock %}
diff --git a/src/projects.thrust b/src/projects.thrust
new file mode 100644
index 0000000..af8d5c6
--- /dev/null
+++ b/src/projects.thrust
@@ -0,0 +1,27 @@
+---
+title: projects
+subtitle: may or may not also be art
+---
+{% extends 'templates/base.html' %}
+{% block body %}
+ <nav>
+ <a href='/'>&gt; index</a>
+ </nav>
+
+ <header>
+ <h1>{{ title }}</h1>
+ <p>{{ subtitle }}</p>
+ </header>
+
+ {% markdown %}
+ Below are "technical" things that I have done, built, or written:
+
+ - [CatBus](/catbus), a home automation platform & associated daemons.
+ - [Improving my home wifi](/wifi) using Raspberry Pis and 802.11r roaming.
+ - [Multi-room audio](/multi-room-audio) using Raspberry Pis and Snapcast.
+ - Using [Tailscale VPN](/tailscale) to securely connect to my home network from the outside internet.
+ - Simplifying services and sandboxing Steam with [systemd Dynamic Users](/systemd-dynamicuser).
+ - Using [YubiKeys](/yubikey) to improve my online security and convenience.
+ - Using [Nix and NixOS](/nixos) for pure-functional and repeatable infrastructure.
+ {% endmarkdown %}
+{% endblock %}
diff --git a/src/publications.thrust b/src/publications.thrust
new file mode 100644
index 0000000..88d34e3
--- /dev/null
+++ b/src/publications.thrust
@@ -0,0 +1,24 @@
+---
+title: publications
+last edited: 2019-11-29
+body: |
+ ## Warps and atomics: Beyond barrier synchronization in the verification of GPU kernels
+
+ - [Paper](http://multicore.doc.ic.ac.uk/tools/GPUVerify/NFM/nfm2014_submission_13.pdf)
+ - [Website](http://multicore.doc.ic.ac.uk/tools/GPUVerify/NFM2014/)
+
+ ## Engineering a Static Verification Tool for GPU Kernels
+
+ - [Paper](http://multicore.doc.ic.ac.uk/tools/GPUVerify/CAV2014/downloads/paper.pdf)
+ - [Website](http://multicore.doc.ic.ac.uk/tools/GPUVerify/CAV2014/)
+
+ ## KernelInterceptor: GPU kernel verification by intercepting kernel parameters
+---
+{% extends 'templates/base.html' %}
+{% block body %}
+ <nav>
+ <a href='/'>&gt; index</a>
+ </nav>
+ <h1>{{ title }}</h1>
+ {{ body | markdown }}
+{% endblock %}
diff --git a/src/resume.thrust b/src/resume.thrust
new file mode 100644
index 0000000..1f3e7f1
--- /dev/null
+++ b/src/resume.thrust
@@ -0,0 +1,62 @@
+---
+title: resume
+last edited: 2020-02-06
+jobs:
+ - where: DeepMind
+ title: Reliability Engineer
+ when: 2017 – 2019
+ points:
+ - Primarily a consulting role for 100+ Researchers, providing knowledge, documentation, and tooling to bridge the gap between the research workflow and Google’s Production environment.
+ - Also working with DeepMind’s Research Platform team and Google's machine-learning infrastructure teams to assist with conventional Production setups.
+ - "Brought a strong focus on UX-for-engineers: tooling and APIs must start from enabling the programmer to specify what they mean."
+ - Was also a member of DeepMind's GDPR and Security working groups.
+
+ - where: Google
+ title: Software Engineer
+ when: 2015 – 2017
+ points:
+ - Worked in Ads Engineering Productivity, Google Ads’ tooling organization.
+ - Lots of release automation work, mostly for local client teams, as well as some Google-wide work.
+
+ - where: Imperial College London
+ title: PhD Candidate
+ when: 2013 – 2015
+ points:
+ - Worked in the Multicore group, making tools to find errors in multi-threaded programs.
+ - Published a paper on a novel abstraction of atomic instructions that allowed checking GPU programs that used them.
+ - Left before completion to work full-time at Google.
+
+education:
+ - 2:1 MEng in Computing from Imperial College.
+ - 4 A-Levels (3 As, 1 B).
+ - 11 GCSEs (A* to C).
+---
+{% extends 'templates/base.html' %}
+{% block body %}
+ <nav>
+ <a href='/'>&gt; index</a>
+ </nav>
+ <article>
+ <h1>Ethel Morgan Resume</h1>
+
+ {% for job in jobs %}
+ <p>
+ <span class='non-breaking'>{{ job.where }}</span>,
+ <span class='non-breaking'>{{ job.title }}</span>,
+ <span class='non-breaking'>{{ job.when }}:</span>
+ </p>
+ <ul>
+ {% for point in job.points %}
+ <li>{{ point }}</li>
+ {% endfor %}
+ </ul>
+ {% endfor %}
+
+ <p>Education:</p>
+ <ul>
+ {% for ed in education %}
+ <li>{{ ed }}</li>
+ {% endfor %}
+ </ul>
+ </article>
+{% endblock %}
diff --git a/src/systemd-dynamicuser.thrust b/src/systemd-dynamicuser.thrust
new file mode 100644
index 0000000..758f3ce
--- /dev/null
+++ b/src/systemd-dynamicuser.thrust
@@ -0,0 +1,161 @@
+---
+title: systemd Dynamic Users
+date: 2020-04-09
+---
+{% extends 'templates/base.html' %}
+{% block body %}
+ <nav>
+ <a href='/projects'>&gt; projects</a>
+ </nav>
+
+ <header>
+ <h1>{{ title }}</h1>
+ </header>
+
+ <article>
+ {% markdown %}
+ The Linux init system [systemd](https://en.wikipedia.org/wiki/Systemd) has its controversies, but it also has some pretty neat features, my favorite of which is _Dynamic Users_.
+ Lennart Poettering's blog has a [full rundown of the ins and outs](http://0pointer.net/blog/dynamic-users-with-systemd.html), but I'll summarize the key points below, and document a few tricks.
+
+ <details markdown='1'>
+ <summary>contents…</summary>
+ <div markdown='block'>
+
+ [TOC]
+
+ </div>
+ </details>
+
+ ## Users and groups and Name Service Switch, oh my!
+
+ On Linux, when you create a user it is usually added to `/etc/passwd`, and groups it is in are added to `/etc/groups`.
+ However, these databases and others can actually come from multiple sources, controlled by the [Name Service Switch](https://en.wikipedia.org/wiki/Name_Service_Switch), and on modern Linux one of those sources is systemd.
+
+ When configured to, systemd will create a user for the lifetime of a Unit to run that Unit.
+ The user is never added to `/etc/passwd`, but exists only at runtime, and when the Unit finishes the user goes away.
+ Although they are very locked down by default, this user is a full Linux user, and can be added to groups, write to disk, or be given capabilities.
+ In particular, systemd provides a system for _State Directories_, which will be writable by the created user, persist after the Unit finishes, and will be fixed-up the next time the Unit runs to be writeable by the next dynamically created user, which can be used to store service data, or to simulate a home directory.
+
+ ## Simple services
+
+ A very useful result of Dynamic Users is to simplify the packaging and running of services.
+ Instead of having to create a user to run a service when it is installed and remove it later when the service is uninstalled, the service package only needs to install a service file as the user is created at runtime.
+ For example, below is the configuration for a web service of mine:
+
+ ```sh
+ $ cat /lib/systemd/system/log.eth.moe
+ [Unit]
+ Description=Backend server for https://log.eth.moe
+
+ [Service]
+ # Create a user at runtime.
+ # It will have a random name.
+ DynamicUser=yes
+
+ # Set the user's primary group to www-data.
+ Group=www-data
+
+ # Create a temporary runtime directory at /run/log-eth-moe/.
+ RuntimeDirectory=log-eth-moe
+
+ # Create a persistent state directory at /var/lib/log-eth-moe/.
+ StateDirectory=log-eth-moe
+
+ ExecStart=/usr/bin/log.eth.moe -socket /run/log-eth-moe/listen.sock -dir /var/lib/log-eth-moe/
+
+ [Install]
+ WantedBy=multi-user.target
+ ```
+
+ It's also generally useful any time you want a non-human user to run something.
+ For example, below is [my configuration](https://github.com/ethulhu/kodi-systemd-service/) for running the [Kodi media center](https://kodi.tv/) on a Raspberry Pi:
+
+ ```sh
+ $ cat /lib/systemd/system/kodi.service
+ [Unit]
+ Description=Kodi Media Center
+ After=systemd-user-sessions.service network.target sound.target
+
+ [Service]
+ # Create a user at runtime.
+ DynamicUser=yes
+
+ # Call that user "kodi".
+ User=kodi
+
+ # Add it to useful groups for a media center.
+ # Note that these are all supplementary groups,
+ # and the dynamic user has no primary group.
+ SupplementaryGroups=audio
+ SupplementaryGroups=input
+ SupplementaryGroups=plugdev
+ SupplementaryGroups=video
+
+ # Create a persistent state directory at /var/lib/kodi.
+ StateDirectory=kodi
+
+ # Set the home directory of the dynamic user to /var/lib/kodi.
+ Environment=HOME=/var/lib/kodi
+
+ ExecStart=/usr/bin/kodi-standalone
+
+ [Install]
+ WantedBy=multi-user.target
+ ```
+
+ ## Sandboxed Steam?
+
+ [Steam](https://store.steampowered.com/) is great, but it also requires running other people's code, so it's best to sandbox it a little.
+ While [some people](https://blog.jessfraz.com/post/docker-containers-on-the-desktop/) might go to the lengths of [running Steam in Docker](http://blog.drwahl.me/steam-running-in-docker-lxc/), I use systemd to achieve similar ends.
+
+ The steps of this approach are:
+
+ - Create a group to share your display with.
+ For myself, `eth`, I created `eth-x11`.
+ - Allow members of that group to access your display.
+ - Run Steam as a member of that group, directed to use your display.
+
+ ### Implementation
+
+ After creating your group, grant that group permission to use your display, substituting `eth-x11` for your group:
+
+ ```sh
+ $ xhost +si:localgroup:eth-x11
+ ```
+
+ This will need to be done every X11 session, and should probably be added to your `.xinitrc` or `.xsession`.
+
+ Then create a script to run Steam under `systemd-run`:
+
+ - Run the command with a TTY so that Steam's log messages are output to your terminal for debugging.
+ - Create a user at runtime.
+ - Set the _primary_ group of the user to be `eth-x11`.
+ The `xhost` command above requires it to be the primary group.
+ - Add the user to the groups `audio`, `input`, and `video`.
+ - Create a `StateDirectory` called `steam`, which `systemd` will put at `/var/lib/steam/`.
+ - Set the `$DISPLAY` to be the current X11 display.
+ - Set the `$HOME` of the running process to `/var/lib/steam/`.
+
+ ```sh
+ $ cat /usr/local/bin/steam
+ #!/bin/sh
+
+ set -eux
+
+ systemd-run \
+ --pty \
+ --property=DynamicUser=yes \
+ --property=Group=eth-x11 \
+ --property=SupplementaryGroups=audio \
+ --property=SupplementaryGroups=input \
+ --property=SupplementaryGroups=video \
+ --property=StateDirectory=steam \
+ --property=Environment=DISPLAY=${DISPLAY} \
+ --property=Environment=HOME=/var/lib/steam \
+ /usr/games/steam
+ ```
+
+ Unfortunately, to use `systemd-run` you must either be root or authenticate yourself to systemd, but the experience can be improved with `sudoers` or setuid on the script.
+ {% endmarkdown %}
+ </article>
+{% endblock %}
diff --git a/src/tailscale.thrust b/src/tailscale.thrust
new file mode 100644
index 0000000..0019dd2
--- /dev/null
+++ b/src/tailscale.thrust
@@ -0,0 +1,164 @@
+---
+title: Tailscale
+date: 2020-04-08
+diagram source: |
+ graph {
+ rankdir="LR";
+
+ subgraph cluster_0 {
+ label = "Tailscale VPN";
+ phone [ label="phone" ];
+ relay [ label="relay" ];
+ server [ label="server" ];
+
+ phone -- relay [ style="dashed" ];
+ phone -- server [ style="dashed" ];
+ relay -- server [ style="dashed" ];
+ }
+
+ subgraph cluster_1 {
+ label = "LAN";
+ bulb [ label="bulb" ];
+ internet [ label="internet" ];
+ router [ label="router" ];
+
+ relay -- bulb [ style="dashed" ];
+ relay -- router [ style="dashed" ];
+ router -- internet;
+ }
+ }
+---
+{% extends 'templates/base.html' %}
+{% block body %}
+ <nav>
+ <a href='/projects'>&gt; projects</a>
+ </nav>
+
+ <header>
+ <h1>{{ title }}</h1>
+ </header>
+
+ <article>
+ {% markdown %}
+ [Tailscale](https://tailscale.com) is a Virtual Private Network (VPN) product for creating simple low-configuration VPNs between Linux, Mac, and Windows computers, as well as iOS devices.
+
+ Rather than a "hub and spoke" model, where all devices dial in to the same VPN server, it builds a peer-to-peer network of [WireGuard](https://www.wireguard.com/) connections between your machines, with Tailscale itself authenticating and arranging those connections.
+
+ I have been using its free tier for a few weeks, and below are some of my notes.
+
+ <details markdown='1'>
+ <summary>contents…</summary>
+ <div markdown='block'>
+
+ [TOC]
+
+ </div>
+ </details>
+
+ ## Bridging a LAN to the VPN
+
+ One nice feature of Tailscale is the ability to bridge existing networks with the VPN.
+ This is very useful for devices in the home that cannot run Tailscale themselves, such as IoT devices.
+
+ Here I will show how I have bridged Tailscale with my LAN.
+ This is an adaptation of the [guide from Tailscale themselves](https://tailscale.com/kb/1019/install-subnets).
+
+ First, some definitions:
+
+ - The **relay node** is the computer on my LAN doing the bridging.
+ - The LAN-facing network interface on my relay node is **`enp2s0`**, but yours may differ.
+ - The subnet for my LAN is **`192.168.16.0/24`**, but yours may differ.
+
+ This example will be using [nftables](https://en.wikipedia.org/wiki/Nftables), but can also be done with [iptables](https://en.wikipedia.org/wiki/Iptables).
+
+ ### In abstract
+
+ <svg xmlns='http://www.w3.org/2000/svg'
+ viewBox='0.00 0.00 404.65 254.00'
+ width='405pt' height='254pt'
+ style='font-family: "Times New Roman", Times, serif; font-size: 12pt; font-weight: lighter;'>
+ <g transform='scale(1 1) rotate(0) translate(4 250)'>
+ <polygon points='8,-144 8,-238 278.42,-238 278.42,-144 8,-144' />
+ <text text-anchor='middle' x='143.21' y='-222.8'>Tailscale VPN</text>
+ <polygon points='199.49,-8 199.49,-136 388.65,-136 388.65,-8 199.49,-8' />
+ <text text-anchor='middle' x='294.07' y='-120.8'>LAN</text>
+ <ellipse cx='47.72' cy='-180' rx='31.94' ry='18' />
+ <text text-anchor='middle' x='47.72' y='-175.8'>phone</text>
+ <ellipse cx='143.22' cy='-170' rx='27.56' ry='18' />
+ <text text-anchor='middle' x='143.22' y='-165.8'>relay</text>
+ <path stroke-dasharray='5,2' d='M79.08,-176.75C90.81,-175.5 104.11,-174.08 115.48,-172.86' />
+ <ellipse cx='238.71' cy='-180' rx='31.92' ry='18' />
+ <text text-anchor='middle' x='238.71' y='-175.8'>server</text>
+ <path stroke-dasharray='5,2' d='M75.69,-188.5C87.72,-191.87 102.15,-195.33 115.44,-197 139.94,-200.07 146.5,-200.07 171,-197 184.28,-195.33 198.71,-191.87 210.74,-188.5' />
+ <path stroke-dasharray='5,2' d='M170.76,-172.84C182.2,-174.06 195.65,-175.5 207.48,-176.77' />
+ <ellipse cx='238.71' cy='-88' rx='27' ry='18' />
+ <text text-anchor='middle' x='238.71' y='-83.8'>bulb</text>
+ <path stroke-dasharray='5,2' d='M160.5,-155.72C177.66,-140.67 204.52,-117.11 221.61,-102.12' />
+ <ellipse cx='238.71' cy='-34' rx='31.45' ry='18' />
+ <text text-anchor='middle' x='238.71' y='-29.8'>router</text>
+ <path stroke-dasharray='5,2' d='M150.17,-152.21C158.49,-129.25 175.35,-88.94 199.49,-61 204.16,-55.59 210.14,-50.71 215.97,-46.63' />
+ <ellipse cx='343.54' cy='-34' rx='37.23' ry='18' />
+ <text text-anchor='middle' x='343.54' y='-29.8'>internet</text>
+ <path d='M270,-34C281.38,-34 294.42,-34 306.28,-34' />
+ </g>
+ </svg>
+
+ 1. Set up Tailscale to route traffic from the VPN into the LAN.
+ 2. Enable packet forwarding inside the Linux kernel on the relay node.
+ This allows the relay node itself to route traffic from the VPN into the LAN.
+ 3. Enable _IP masquerading_ on the relay node.
+ This a form of Network Address Translation (NAT) to make traffic from the VPN to the LAN appear to come from the relay node.
+
+ ### Setting it up
+
+ <ol>
+ <li>
+ <p>On the relay node, run:</p>
+ <pre><code>$ sudo tailscale up -advertise-routes=192.168.16.0/24</code></pre>
+ </li>
+
+ <li>
+ <p>Go to the <a href='https://login.tailscale.com/admin/machines'>Tailscale admin console</a> and authorize subnet routes for the relay node.</p>
+ </li>
+
+ <li>
+ <p>Back on the relay node, enable IP forwarding:</p>
+ <pre><code>$ echo 1 | sudo tee /proc/sys/net/ipv4/ip_forward</code></pre>
+ </li>
+
+ <li>
+ <p>Enable IP masquerading for the _LAN-facing_ interfaces:</p>
+ <pre><code>$ sudo nft add rule ip nat POSTROUTING oifname "enp2s0" counter masquerade</code></pre>
+ <p>Alternatively, for iptables:</p>
+ <pre><code>$ sudo iptables -t nat -A POSTROUTING -j MASQUERADE -o enp2s0</code></pre>
+ </li>
+
+ <li>
+ <p>Confirm it works by pinging a machine on your LAN from a machine that's not, for example pinging <code>192.168.16.1</code> from a phone with the Tailscale VPN on mobile data.</p>
+ </li>
+ </ol>
+
+ ### Making it persistent
+
+ To make enable IP forwarding on boot:
+
+ ```sh
+ $ cat /etc/sysctl.d/50-forwarding.conf
+ net.ipv4.ip_forward=1
+ ```
+
+ To enable NAT on boot with nftables, add the following to `/etc/nftables.conf`:
+
+ ```
+ table ip tailscale_nat {
+ chain postrouting {
+ type nat hook postrouting priority 100; policy accept;
+ oifname "enp2s0" masquerade
+ }
+ }
+ ```
+
+ Reboot the relay node and confirm that it all still works.
+ {% endmarkdown %}
+ </article>
+{% endblock %}
diff --git a/src/ve-day-propaganda.thrust b/src/ve-day-propaganda.thrust
new file mode 100644
index 0000000..e994c0a
--- /dev/null
+++ b/src/ve-day-propaganda.thrust
@@ -0,0 +1,71 @@
+---
+title: Propaganda as Cultural Toxic Waste
+subtitle: decomission that shit
+syndication:
+ twitter: https://twitter.com/0xtentakitty/status/1258802023140667392
+date: 2020-05-19
+---
+{% extends 'templates/base.html' %}
+{% block body %}
+ <nav>
+ <a href='/'>&gt; home</a>
+ </nav>
+
+ <header>
+ <h1>{{ title }}</h1>
+ <p>{{ subtitle }}</p>
+ </header>
+
+ <article>
+ {% markdown %}
+ Rambly VE day thoughts: Propaganda as Cultural Toxic Waste.
+
+ TL;DR: Propaganda works, even on unintended audiences, it needs responsible disposal after use, and leftover propaganda from WW2 and the Cold War has left the UK with a broken understanding of the world.
+
+ ---
+
+ 2 examples: I used to know someone who was very British, would become annoyed at my use of US instead of UK English in writing, but was strangely in favor of "our" military.
+
+ If the NHS had shared data with the GRU, alarms would be ringing, but Palantir is our CIA-backed friend.
+
+ ---
+
+ In movies, games, online discourse, and even the news, we are all USian.
+ The US president is _the_ president.
+ The US military is _the_ military.
+ US propaganda intended for US citizens is broadcast at all of us, and some of it sticks.
+
+ ---
+
+ War propaganda in particular is a quick and dirty hack, to build motivation and morale against a specific enemy in a short space of time.
+ The enemy is not just the enemy, they are evil, and we are pure.
+ We fight not for survival or for gains, but for justice.
+
+ ---
+
+ But it sticks around afterward, children growing up steeped in it without the context of lived experience of the war, and so the rousing emotive speech meant to give hope and direct the anger of a city just bombed becomes word-for-word truth.
+
+ ---
+
+ Without the context of the Soviet empire steadily annexing West across Europe, a difference in economic philosophy between us and the competing empire becomes a valid justifiction to end the world in nuclear fire.
+
+ ---
+
+ German Nazis were bad because they were German.
+
+ Russian Communists were bad because they were Communists.
+
+ Both of these framings have consequences.
+
+ ---
+
+ And so now peace in Western Europe is actually a secret WW3 because Germans can't stop being Germans, while Russia is no concern because they have a stock market now, and criticizing our own economic situtation is forbidden because neoliberalism is how we "beat" the Soviets.
+
+ ---
+
+ "But this propaganda benefits the establishment".
+
+ I doubt it. Decades of being told that the West's problem with Russia was its ideology lead us to believe that the Cold War was automatically over when they had a change in government. The propaganda stuck, and it became truth.
+ {% endmarkdown %}
+ </article>
+{% endblock %}
diff --git a/src/wifi.thrust b/src/wifi.thrust
new file mode 100644
index 0000000..26cbdc7
--- /dev/null
+++ b/src/wifi.thrust
@@ -0,0 +1,350 @@
+---
+title: home WiFi setup
+configs:
+ router:
+ /etc/systemd/network/wan.network: |
+ [Match]
+ # enp1s0 is the name of my router's WAN interface.
+ Name=enp1s0
+
+ [Network]
+ # take a IP from my ISP's modem over DHCP.
+ DHCP=yes
+
+ # use Google's public DNS servers.
+ DNS=8.8.8.8
+ DNS=8.8.4.4
+
+ # prefer DNS-over-TLS if available.
+ DNSOverTLS=opportunistic
+
+ # forward packets from this interface to other interfaces.
+ IPForward=yes
+
+ [DHCP]
+ # some ISPs' DNS invent A records, so don't use them.
+ UseDNS=no
+
+ /etc/systemd/network/bridge.netdev: |
+ [NetDev]
+ Name=br0
+ Kind=bridge
+
+ /etc/systemd/network/lan.network: |
+ [Match]
+ Name=enp2s0 enp3s0
+
+ [Network]
+ Bridge=br0
+
+ /etc/systemd/network/bridge.network: |
+ [Match]
+ Name=br0
+
+ [Network]
+ # the router's LAN IP.
+ Address=192.168.16.1/24
+
+ # run a DHCP server.
+ DHCPServer=yes
+
+ # forward packets from this interface to other interfaces.
+ IPForward=yes
+
+ # make forwarded packets appear to come from this device,
+ # a.k.a. enable NAT.
+ IPMasquerade=yes
+
+ [DHCPServer]
+ DefaultLeaseTimeSec=600
+
+ # LAN DNS & Stubby DNS-over-TLS bridge.
+ DNS=192.168.16.1
+
+ # fallback DNS.
+ DNS=8.8.8.8
+ DNS=8.8.4.4
+
+ bouncer:
+ /etc/systemd/network/bridge.netdev: |
+ [NetDev]
+ Name=br0
+ Kind=bridge
+
+ /etc/systemd/network/wired.network: |
+ [Match]
+ Name=eth0
+
+ [Network]
+ Bridge=br0
+
+ /etc/systemd/network/bridge.network: |
+ [Match]
+ Name=br0
+
+ [Network]
+ DHCP=yes
+ DNSOverTLS=opportunistic
+
+ shared:
+ /etc/hostapd/hostapd.conf: |
+ bridge=br0
+ interface=wlan0
+ driver=nl80211
+ ssid=something-witty
+
+ # select a channel automatically
+ # using the ACS survey-based algorithm,
+ # instead of setting a channel manually.
+ channel=0
+ country_code=GB
+ ieee80211d=1
+
+ # hw_mode=g for 802.11n, hw_mode=a for 802.11ac.
+ hw_mode=g
+
+ # 802.11n support.
+ ieee80211n=1
+ wmm_enabled=1
+
+ # WPA2 encryption settings.
+ # note that wpa_key_mgmt also has FT-PSK for 802.11r.
+ wpa=2
+ wpa_passphrase=something-secret
+ wpa_key_mgmt=WPA-PSK FT-PSK
+ wpa_pairwise=TKIP
+ rsn_pairwise=CCMP
+
+ # 802.11i support.
+ rsn_preauth=1
+ rsn_preauth_interfaces=br0
+
+ # 802.11r support.
+ # mobility_domain must be shared across APs.
+ # nas_identifier must be different between APs.
+ mobility_domain=19fc
+ nas_identifier=8e71540f0467
+ ft_psk_generate_local=1
+diagram: |
+ <svg xmlns='http://www.w3.org/2000/svg'
+ viewBox='0.00 0.00 466.59 106.00'
+ width='467pt' height='106pt'
+ style='font-family: "Times New Roman", Times, serif; font-size: 12pt; font-weight: lighter;'>
+ <g id='graph0' class='graph' transform='scale(1 1) rotate(0) translate(4 102)'>
+ <title>home network diagram</title>
+ <g id='home-network-diagram/internet' class='node'>
+ <title>internet</title>
+ <polygon fill='none' points='43.19,-8.05 45.61,-8.15 48.01,-8.3 50.37,-8.49 52.69,-8.74 54.96,-9.03 57.16,-9.36 59.29,-9.75 61.34,-10.18 63.31,-10.65 65.18,-11.16 66.95,-11.71 68.61,-12.31 70.17,-12.94 71.6,-13.61 72.92,-14.31 74.11,-15.04 75.18,-15.8 76.11,-16.59 76.92,-17.41 77.6,-18.25 78.15,-19.11 78.57,-19.99 78.87,-20.89 79.04,-21.8 79.08,-22.72 79.01,-23.65 78.81,-24.59 78.51,-25.53 78.1,-26.47 77.58,-27.41 76.96,-28.35 76.25,-29.28 75.46,-30.2 74.57,-31.11 73.61,-32.01 72.58,-32.89 71.47,-33.75 70.31,-34.59 69.09,-35.41 67.81,-36.2 66.49,-36.96 65.13,-37.69 63.73,-38.39 62.29,-39.06 60.83,-39.69 59.33,-40.29 57.82,-40.84 56.28,-41.35 54.73,-41.82 53.16,-42.25 51.58,-42.64 49.99,-42.97 48.4,-43.26 46.79,-43.51 45.18,-43.7 43.57,-43.85 41.95,-43.95 40.34,-44 38.72,-44 37.1,-43.95 35.48,-43.85 33.87,-43.7 32.26,-43.51 30.66,-43.26 29.06,-42.97 27.47,-42.64 25.89,-42.25 24.32,-41.82 22.77,-41.35 21.24,-40.84 19.72,-40.29 18.23,-39.69 16.76,-39.06 15.33,-38.39 13.92,-37.69 12.56,-36.96 11.24,-36.2 9.97,-35.41 8.74,-34.59 7.58,-33.75 6.48,-32.89 5.44,-32.01 4.48,-31.11 3.6,-30.2 2.8,-29.28 2.09,-28.35 1.47,-27.41 0.96,-26.47 0.54,-25.53 0.24,-24.59 0.05,-23.65 -0.03,-22.72 0.02,-21.8 0.19,-20.89 0.48,-19.99 0.9,-19.11 1.45,-18.25 2.13,-17.41 2.94,-16.59 3.88,-15.8 4.94,-15.04 6.14,-14.31 7.45,-13.61 8.89,-12.94 10.44,-12.31 12.1,-11.71 13.88,-11.16 15.75,-10.65 17.71,-10.18 19.76,-9.75 21.89,-9.36 24.1,-9.03 26.36,-8.74 28.68,-8.49 31.05,-8.3 33.44,-8.15 35.87,-8.05 38.31,-8 40.75,-8 43.19,-8.05'/>
+ <text text-anchor='middle' x='39.53' y='-21.8'>internet</text>
+ </g>
+ <g id='home-network-diagram/router' class='node'>
+ <title>router</title>
+ <polygon fill='none' points='170.05,-44 116.05,-44 116.05,-8 170.05,-8 170.05,-44'/>
+ <text text-anchor='middle' x='143.05' y='-21.8'>router</text>
+ </g>
+ <g id='edge1' class='edge'>
+ <title>internet -- router</title>
+ <path fill='none' d='M78.33,-26C90.85,-26 104.49,-26 115.93,-26'/>
+ </g>
+ <g id='node3' class='node'>
+ <title>kitchen</title>
+ <polygon fill='none' points='327.91,-78 270.49,-78 270.49,-42 327.91,-42 327.91,-78'/>
+ <text text-anchor='middle' x='299.2' y='-55.8'>kitchen</text>
+ </g>
+ <g id='edge2' class='edge'>
+ <title>router -- kitchen</title>
+ <path d='M170.2,-31.77C198.03,-37.91 241.9,-47.58 270.36,-53.86'/>
+ <text text-anchor='middle' x='220.33' y='-51.8'>powerline</text>
+ </g>
+ <g id='node4' class='node'>
+ <title>laptop</title>
+ <text text-anchor='middle' x='425.31' y='-13.8'>laptop</text>
+ </g>
+ <g id='edge3' class='edge'>
+ <title>router -- laptop</title>
+ <path fill='none' stroke-dasharray='5,2' d='M170.21,-24.18C195.79,-22.49 235.85,-20.07 270.6,-19 315.41,-17.62 367.61,-17.62 398.19,-17.78'/>
+ <text text-anchor='middle' x='299.2' y='-21.8'>WiFi</text>
+ </g>
+ <g id='edge4' class='edge'>
+ <title>kitchen -- laptop</title>
+ <path stroke-dasharray='5,2' d='M328.05,-50.57C349.07,-43.46 377.69,-33.78 398.25,-26.82'/>
+ <text text-anchor='middle' x='359.92' y='-45.8'>WiFi</text>
+ </g>
+ <g id='node5' class='node'>
+ <title>bulb</title>
+ <text text-anchor='middle' x='425.31' y='-75.8'>lightbulb</text>
+ </g>
+ <g id='edge5' class='edge'>
+ <title>kitchen -- bulb</title>
+ <path stroke-dasharray='5,2' d='M328.05,-64.49C347.06,-67.55 372.3,-71.62 392.17,-74.82'/>
+ <text text-anchor='middle' x='359.92' y='-73.8'>WiFi</text>
+ </g>
+ </g>
+ </svg>
+---
+{% extends 'templates/base.html' %}
+{% block body %}
+ <nav>
+ <a href='/projects'>&gt; projects</a>
+ </nav>
+
+
+ <article>
+ <h1>{{ title }}</h1>
+
+{% macro file(d, path) %}
+<pre><code>$ cat {{ path }}
+{{ d[path] }}</code></pre>
+{% endmacro %}
+
+{% markdown %}
+<details markdown='1'>
+<summary>contents…</summary>
+<div markdown='block'>
+
+[TOC]
+
+</div>
+</details>
+
+There are two Access Points (APs) in my house: one in the living room, and one in the kitchen.
+They are connected via LAN-over-powerline, and have WiFi Roaming set up for a seamless connection throughout.
+
+{{ diagram | safe }}
+
+## Roaming
+
+WiFi Roaming allows a client device (e.g. a phone) to transparently switch from one AP to another.
+Unlike "repeaters" or "boosters", which create a second WiFi network that the client needs to disconnect from and reconnect to, Roaming is _the same network_ across APs and allows for a seamless experience.
+
+This is implemented with two extensions of the WiFi spec, _802.11i_ and _802.11r_:
+
+- _802.11i_ is pre-authentication, which allows clients to start associating to a new AP while still connected to the previous one.
+- _802.11r_ is Fast Transition (FT), where APs can share keys with each other.
+ On large networks (e.g. corporate ones) this is done properly where the APs communicate and use intermediate keys and the like, but for a home network with a Pre-Shared Key (PSK), the APs can derive all the keys themselves.
+
+<details markdown='1'>
+<summary>How does 802.11r actually work?</summary>
+<div markdown='block'>
+In a given roaming connection, there are 3 keys:
+
+- Master Session Key (MSK).
+- R0, a key derived from the MSK.
+- R1, a key derived from R0.
+
+There are 3 players:
+
+- The client.
+- R0KH, the R0 Key Handler, may or may not be an AP.
+- R1KH, the R1 Key Handler, an AP.
+
+In "pull mode":
+
+1. The client connects to the first AP.
+2. The client is given a key and an R0KH-ID (the _NAS Identifier_, named for [RADIUS'](https://en.wikipedia.org/wiki/RADIUS) [Network Access Server](https://en.wikipedia.org/wiki/Network_access_server)).
+3. The client connects to the second AP.
+4. The client gives R0KH-ID to the second AP.
+5. The second AP, now R1KH, contacts R0KH using the ID and its own R1KH-ID.
+6. R0KH sends R1KH a key R1 derived from R0 and R1KH-ID.
+7. R1KH, gives the client R1.
+
+In "push mode":
+
+1. R0KH knows all its APs.
+2. R0KH sends each AP a unique R1 derived from R0 and the AP's R1KH-ID.
+3. the client connects normally.
+
+For PSK mode, the MSK is the PSK, so any AP can generate R0 and R1 for any NAS Identifier.
+</div>
+</details>
+
+## Router configuration { #router }
+
+My router is a [PC Engines APU 1d4](https://www.pcengines.ch/apu1d4.htm), which has:
+
+- 3 ethernet ports.
+- 1 wifi card.
+
+One of the ethernet ports is for WAN, and the other two and wifi card are LAN.
+The LAN interfaces are joined with a bridge, `br0`.
+
+### WAN { #router/wan }
+
+This is a fairly normal [`systemd-networkd`](https://wiki.archlinux.org/index.php/Systemd-networkd) client config, with the addition of `IPForward=yes`:
+
+{{ file( configs.router, '/etc/systemd/network/wan.network' ) }}
+
+### LAN { #router/lan }
+
+The LAN is controlled with `systemd-networkd` where possible, and the wifi is managed by `hostapd`.
+There are three parts to the `networkd` configuration:
+
+- Create a bridge network device.
+- Connect the LAN ethernet ports to it.
+- Configure the bridge to be a DHCP server & NAT gateway.
+
+First, create a bridge `br0`:
+
+{{ file( configs.router, '/etc/systemd/network/bridge.netdev' ) }}
+
+Connect the LAN ethernet ports to the bridge:
+
+{{ file( configs.router, '/etc/systemd/network/lan.network' ) }}
+
+Finally, configure the local network on the bridge itself.
+
+- `systemd-networkd` includes a minimal DHCP server, so also enable that.
+- Note the addition of both `IPForward=yes` and `IPMasquerade=yes` to set up NAT.
+
+{{ file( configs.router, '/etc/systemd/network/bridge.network' ) }}
+
+### WiFi { #router/wifi }
+
+Because `hostapd` has its own bridge management, it isn't included in the `systemd-networkd` configuration.
+
+This configuration:
+
+- Uses our bridge `br0`.
+- Uses the standard Linux WiFi driver set (others exist for other chipsets).
+- Automatically selects a channel at start.
+- Enables 802.11n (`hostapd` also supports 802.11ac).
+- Enables WPA2 with a Pre-Shared Key (PSK).
+- Enables pre-authentication (802.11i) on our bridge `br0`.
+- Enables roaming (802.11r):
+ - `nas_identifier` must be 6 bytes, and different between APs. I use the interface MAC.
+ - `mobility_domain` must be 2 bytes, and is shared between APs.
+
+{{ file( configs.shared, '/etc/hostapd/hostapd.conf' ) }}
+
+## Kitchen
+
+The WiFi in the kitchen is provided by a [Raspberry Pi 2B](https://www.raspberrypi.org/products/raspberry-pi-2-model-b/) and a USB WiFi adapter that supports _host mode_.
+I chose this hardware because I already owned it.
+
+### LAN { #kitchen/lan }
+
+This is a simpler version of the [Router's LAN configuration](#router/lan), but with only two steps:
+
+- Create a bridge network device.
+- Connect the LAN ethernet ports to it.
+
+As before, create a bridge `br0`:
+
+{{ file( configs.bouncer, '/etc/systemd/network/bridge.netdev' ) }}
+
+Connect the ethernet port to the bridge:
+
+{{ file( configs.bouncer, '/etc/systemd/network/wired.network' ) }}
+
+Configure the local network on the bridge itself.
+Because this is not the primary router, it can be just another DHCP client:
+
+{{ file( configs.bouncer, '/etc/systemd/network/bridge.network' ) }}
+
+
+### WiFi { #kitchen/wifi }
+
+This is basically the same as the [Router's WiFi configuration](#router/wifi), although remember to change the `nas_identifier`!
+{% endmarkdown %}
+ </article>
+{% endblock %}
diff --git a/src/writings.thrust b/src/writings.thrust
new file mode 100644
index 0000000..0220274
--- /dev/null
+++ b/src/writings.thrust
@@ -0,0 +1,31 @@
+---
+title: writings
+subtitle: stories & fiction
+pages:
+ - writings/piracy
+ - writings/aissist
+ - writings/helena
+ - writings/chair
+ - writings/bruges
+ - writings/chesham
+---
+{% extends 'templates/base.html' %}
+{% block body %}
+ <nav>
+ <a href='/'>&gt; index</a>
+ </nav>
+
+ <header>
+ <h1>{{ title }}</h1>
+ <p>{{ subtitle }}</p>
+ </header>
+
+ <nav>
+ <ul>
+ {% for path in pages %}
+ {% set thrust = ( 'src/' + path + '.thrust' ) | loadthrust %}
+ <li><a href='/{{ path }}''>{{ thrust.data.title }} ({{ ( ( ( thrust.data.body | wordcount ) / 10.0 ) | round | int ) * 10 }} words)</li>
+ {% endfor %}
+ </ul>
+ </nav>
+{% endblock %}
diff --git a/src/writings/aissist.thrust b/src/writings/aissist.thrust
new file mode 100644
index 0000000..e762896
--- /dev/null
+++ b/src/writings/aissist.thrust
@@ -0,0 +1,59 @@
+---
+layout: writings
+title: AIssist
+date: 2016-03-24
+body: |
+ ## Woman released after AI implicated in crime
+
+ And in other news, a Lithia Springs woman was exonerated today after it was
+ revealed than an AlphaCorp personal assistant had caused her to commit her
+ crimes.
+
+ Medbh O’Connor was sentenced to 30 years off-world mining after poisoning her
+ step daughter, Yuki Hayes. The presiding judge said that her demeanor was
+ callous and self-righteous after a crime of jealousy. However, that case was
+ appealed after the recent AlphaCorp leak, which included a document that
+ indicated that her AIssistant had caused the crime.
+
+ The program had mistaken her life for the plot of the Disney movie Snow White,
+ and took steps to manipulate and psychologically torture her until she killed
+ her step child with an apple coated in cyanide.
+
+ She is now suing AlphaCorp.
+notes: |
+ After [a man blamed Uber for a killing spree](http://arstechnica.co.uk/tech-policy/2016/03/uber-driver-app-killing-spree/)
+ it came up on [Slack](http://cyberpunkfuturism.slack.com):
+
+ > It's easy to turn otherwise 'normal' people into murderers, or passive
+ > beneficiaries of murder. The methodology has already been worked out and
+ > turned into a process. It's just an extension of commercial marketing.
+ >
+ > You just have to convince them that A) it's okay to be a murderer for the
+ > right reasons, and that B) there are people who are so well qualified to
+ > distinguish between the right reasons and the wrong reasons that they
+ > should be permitted to do so without oversight. And then that C) you're one
+ > of those qualified people. These ideas are easy to push if you've got
+ > enough cultural bandwidth, or the ability to artificially reduce the
+ > prevalence of opposing views.
+ >
+ > If you can convince people that there's a moral high ground to occupy by avoiding exposure to opposing viewpoints, you're halfway there already.
+ >
+ > So don't touch that dial.
+
+ So I wrote up a short for [r/blastfromthefuture](http://reddit.com/r/blastfromthefuture).
+syndication:
+ Reddit: https://www.reddit.com/r/blastfromthefuture/comments/4bszij/woman_released_after_ai_implicated_in_crime/
+---
+{% extends 'templates/base.html' %}
+{% block body %}
+ <nav>
+ <a href='/writings'>&gt; writings</a>
+ </nav>
+ <header>
+ <h1>{{ title }}</h1>
+ </header>
+
+ <article>{{ body | markdown }}</article>
+ <hr />
+ <aside>{{ notes | markdown }}</aside>
+{% endblock %}
diff --git a/src/writings/bruges.thrust b/src/writings/bruges.thrust
new file mode 100644
index 0000000..03e81e3
--- /dev/null
+++ b/src/writings/bruges.thrust
@@ -0,0 +1,109 @@
+---
+title: Bruges
+date: 2014-06-20
+body: |
+ Endless twisting roads.
+ I kept running, ever conscious of the footsteps behind me, its march keeping to the drumbeat of my heart.
+ I took a left, almost falling from skidding on the torrential downpour.
+ Tap.
+ Tap.
+ Tap.
+
+ ---
+
+ After my recent submission to a collection curated by messrs McIntosh and Bergen, I took my leave for Bruges.
+ I had heard much of the Belgian air's restorative effects, and I was tired from my studies, and so I found myself but 3 days ago leaving the comfortable bosom of London for the continent.
+ The journey was largely uneventful, although I found myself attempting to leave the train at Aalter, as if in a trance, unthinkingly taking my luggage and walking to the door.
+ I came to as I had nearly stepped out, and returned to my seat.
+ I now wish I had not, and allowed whatever providence to succeed in its attempt to save me.
+
+ Upon reaching Bruges, I attempted to find my lodging, a small boarding house near one of the city gates.
+ I had procured a map while in London, but none of the streets seemed to quite match, and were named differently when they did.
+ I walked at least one circle of the maze, the trudging of my feet like a shambling chant, walking out some kind of prayer to be shown rest.
+ But as that thought crossed my mind, the architecture yielded, and it was before me, where surely it was not before.
+
+ I checked into the hotel, and went for a simple dinner in the small square a short walk away.
+ The mussels were delicious, the escargot divine, the beer rich and dark.
+ The streets, previously desolate and foreboding, were now filled with all kinds of people, bustling and scurrying to avoid the rain, suddenly pouring from the sky as it was.
+ I idly watched them to and fro, before the sky darkened, the rain stopped, and they disappeared.
+ I paid and left, and found myself stood on a bridge looking out over one of the many canals, and shuddered.
+ The river, its black mass seething and writhing beneath, gurgling as if digesting its last victim, hungry.
+ Even though it has no eyes, I knew it watched me pass.
+ It watched, and I shuddered, with something even more primal than fear, something that left me transfixed, held as if by the hand of God Himself, if such a thing can even exist in the face of this... thing below.
+ By the time I returned to my body and came to my senses, the shadow in the water was gone.
+ I stumbled back to my lodgings, shaken.
+
+ The day that followed was agreeable, visiting local collections and enjoying delicacies.
+ The rain left me soaked as I walked, and I often found myself at a loss attempting to comprehend the geometry of the place, but suddenly it was refreshing, almost comforting in a way I can't quite describe but felt so assuredly.
+
+ There was one moment of unease, however.
+ Within a collection devoted to showcasing local produce, I came upon a scene of such macabre intentions I can barely comprehend.
+ A vision of a man consuming his own flesh, as if overcome by madness, not for want of food, but of such warped senses as to find it appetizing.
+ Next to them, a woman offered up her child, like a basket of delicious fruits, insane and twisted.
+ Confused and unsettled, I moved on into the safety of the rain.
+
+ I woke the following morning, packed my things, said my goodbyes, and started toward the station.
+ I inevitably found myself lost, the maddening layout mocking me as I walked in circles again.
+ I could not even try to ask a local for guidance, as the streets were once more empty.
+ After an hour of this, the sun beating down on me, I tried to stop for a lunch, or perhaps one of the Trappist brews, but found no-where open.
+ A bell tolled in the near distance, hidden behind the walls of this maze, so close and yet obscured and concealed.
+
+ I turned, looked at my map, turned again.
+ Everywhere, a winding road of esoteric yet uniform architecture, devoid of any kind of life.
+ "Gieterijstraat", "Witteleertouwesstraat", "Minderbroederstraat", the language as arcane as the streets themselves, seemingly assembled without form or reason.
+ It was at this point that I knew.
+ The city had caught me, and now it was simply playing.
+
+ I walked and walked, growing tired and hungry.
+ I longed for the rain, to cool me in the sun and as reassurance that time was truly passing.
+ But with it came the footsteps.
+ When I first heard them, I felt joy, but it soon turned to terror as I saw what was making them, for I had seen it in the eyes of the man feasting on himself.
+ I turned and ran.
+ The footsteps followed, with a slow yet constant pacing, never matching my speed but always growing closer.
+ I ran and it followed, waiting for me to tire and fall.
+
+ The rain lifted, and with it went the footsteps.
+ I was safe, of a kind, but just as trapped as before.
+ I rested for a few moments, before stumbling forward in hope of finding a gate before the rain returned, my body aching.
+
+ But it rained again.
+ And again, and again, through day and night.
+ I do not know how many frozen cold wet nights I ran for.
+ It never quite reached me, but it was often close, its scent lingering, mixing with my own newfound smell of decay.
+ Even though I knew there was no exit from this foul place, I kept running, hope alone keeping me mobile.
+
+ Days turned to weeks, and I collapsed, exhausted and starving, not even having the strength to fear anymore.
+ I willed it to catch me, to consume me through myself.
+ When the rain came that time, I did not hear the footsteps.
+
+ I am told I spent only 3 days abroad, although they will admit I looked somehow aged.
+ I escaped with my life, but I am less sure of my soul, and know I have lost my mind.
+ My nurse chatters to herself as she tidies my bedsheets and nightstand.
+ I pick at my skin and start to salivate.
+notes: |
+ This was a summary of a [student union society](https://www.union.ic.ac.uk/rcc/meat/)
+ trip to Bruges for the student newspaper, [Felix](http://felixonline.co.uk).
+ At breakfast on the second day, a writeup for Felix came up, and as we were all
+ members of the [science fiction society](http://icsf.org.uk) we thought it would
+ be fun to do one each in different styles. Mine was, [again](../chesham), gothic horror.
+
+ It's largely factual if skewed; we did nearly get off at the wrong station, and
+ got lost a lot. The pub is probably the one at the corner of Langestraat and
+ Molenmeers (it was 2014, I'm writing this in 2016, and it's not readily
+ searchable). The cannibals were from a statue in the [chocolate museum](http://www.choco-story.be/ENG).
+
+ It was published in [Issue 1581 of Felix](http://felixonline.co.uk/issuearchive/issue/1393/download/) (page 37).
+---
+{% extends 'templates/base.html' %}
+{% block body %}
+ <nav>
+ <a href='/writings'>&gt; writings</a>
+ </nav>
+ <header>
+ <h1>{{ title }}</h1>
+ </header>
+
+ <article>{{ body | markdown }}</article>
+ <hr />
+ <aside>{{ notes | markdown }}</aside>
+{% endblock %}
diff --git a/src/writings/chair.thrust b/src/writings/chair.thrust
new file mode 100644
index 0000000..e0a1b73
--- /dev/null
+++ b/src/writings/chair.thrust
@@ -0,0 +1,72 @@
+---
+layout: writings
+title: Chair
+date: 2016-02-04
+notes: |
+ This was the first short story for my current writing group in early 2016. The
+ stimulus was "chair", simply because we needed a stimulus and there were chairs
+ in the room and we ran with it.
+body: |
+ The chair lay in one corner of the room, slightly angled toward the window, the
+ sun mottled on the cracked leather. Empty. The client sat in a chair opposite,
+ and Jenine perched on the sofa. The clock ticked.
+
+ "How are you, Mrs Bateman?"
+
+ "I'm good thank you, dear. Tired, but almost bored. Empty nest syndrome no
+ doubt. John's away on business again. Celia Vrije keeps bringing me food,
+ 'checking to see if I'm OK'. You'd almost think there'd been a death in the
+ family."
+
+ Mrs Bateman patted the arm of the empty chair. Jenine nodded, smiled, took the
+ nearest of the three cups of tea, put it to her lips, before holding it. You're
+ not supposed to consume things given to you by clients, but refusing
+ hospitality usually doesn't go well.
+
+ "How is John."
+
+ "Oh you know, always out. Working, picking the kids up from school, at the
+ allotment. He's started getting up and out of the house before me. I swear I've
+ hardly seen him all week."
+
+ "How are the children."
+
+ "They haven't been in touch lately, not since they took the car to move Gin
+ into halls. No doubt off having crazy adventures. I remember what it was like,
+ exploring the world, exploring ourselves, without a care in sight. I'm sure you
+ remember. You can't be much older than my Eli."
+
+ "Hah, yes, I do. Wild times."
+
+ Build rapport with the client, ensure they're comfortable, listened to.
+ Connect, but not enough to risk getting involved or, worse, them becoming
+ dependent.
+
+ "It has been months since they called, though."
+
+ A moment of silence, a quick flash of something across her face, before it
+ reset.
+
+ "Now, remind me again what it was you were here for?"
+
+ "I'm here to see how you are."
+
+ "Oh god, like Celia from next door. I'm fine. Where has this idea that there's
+ something wrong with me come from? Just a little stressed and, I suppose,
+ lonely. But I'll find new hobbies now the kids have left, and I still have
+ John. Thinking of, he should be home soon, you only just missed him popping out
+ to the shops."
+---
+{% extends 'templates/base.html' %}
+{% block body %}
+ <nav>
+ <a href='/writings'>&gt; writings</a>
+ </nav>
+ <header>
+ <h1>{{ title }}</h1>
+ </header>
+
+ <article>{{ body | markdown }}</article>
+ <hr />
+ <aside>{{ notes | markdown }}</aside>
+{% endblock %}
diff --git a/src/writings/chesham.thrust b/src/writings/chesham.thrust
new file mode 100644
index 0000000..ca372dc
--- /dev/null
+++ b/src/writings/chesham.thrust
@@ -0,0 +1,74 @@
+---
+layout: writings
+title: Chesham
+date: 2014-01-01
+body: |
+ I had received a summons from a family friend, telling me that I must travel to her as soon as possible, for matters of the utmost importance that could only be spoken in person.
+ I knew her to not be the kind to speak in exaggeration, and so packed a bag and left as quickly as I could.
+
+ The train out to the village was as uneventful as one would expect, until it reached the penultimate stop.
+ When the doors opened, I noticed only silence, snaking itself around me until I was wrapped in it and its accompanying sense of foreboding.
+ The train stood, doors open, as though beckoning me out in hope, for some minutes, before begrudgingly closing them and lurching on again toward the final stop.
+
+ The railway from there is a long single track winding its way up the hill.
+ We soon passed into fog, and the train started screaming, as though in fear. As it ached further and further up the hill, the sense of foreboding grew, until finally the train came to a halt, just long enough to spit me out into the chill, before escaping back down the hill to safety.
+ As I made my way along the platform, I caught, out of the corner of my eye, three bright red dots, piercing through the mist.
+
+ Outside the station, I was met by the cousin, and we descended the hill toward her homestead.
+ She did not mention the matter that had troubled so to call me here, but I was feeling somewhat drained from the journey and decided to let her raise it in her own time.
+ We dined and discussed trivialities, before retiring to bed.
+
+ The following morning, we went through the town to the butcher for that evening's meal.
+ As we walked, I studied the buildings we passed with a kind of fascination.
+ The terrace row she lived on lead into warehouses, before meeting a road headed by a grand building I assumed must be a church.
+ It felt like it was not one village, but several, from disparate times and places, as though built, not by the hands of men, but by memories, superimposed upon one another.
+
+ As we reached the main street, the wrongness intensified.
+ I could see the end of the street, or right in front of me - to attempt focussing on the middle hurt and stung my eyes.
+ We made our purchases, before heading opposite to a coffee house.
+
+ As we sat down with the steaming invigorating brews, a couple entered.
+ I merely noted their entry, and thought nothing of them, until they entered the coffee house a second time.
+ I sniffed my drink to ensure it had not been altered, and studied them in closer detail to find some different feature or indication of play disguise, but, albeit in different clothing, they were most definitely the same pair.
+ My host seemed unperturbed by this.
+
+ Eventually, I asked her the reason for requesting my presence.
+
+ "Oh, I thought it might be nice for you to get out of the city for a bit.
+ To relax and unwind"
+
+ This response left me confused and wary.
+ In her letter, she had seemed urgent and distraught, and she had never been the kind for such frivolous correspondence.
+ I thanked her for her sentiment and care for my wellbeing, but resolved to discover her real motive in bringing me out here.
+
+ The main street felt more uncomfortable on the return journey, with the same strange sick feeling at trying to gauge its length.
+ Upon turning the corner back to her house, I noticed a sign that I didn't recall seeing on the way out, or the previous day.
+ "To the spiritualist church", daubed on the wall of a house in stark black and white.
+ A little way on, in a gap in the terrace where houses should have been, was a wooden shack, with a small cross made from simple crossed timber affixed to the top.
+ It stood alone in an otherwise empty plot, and a sign on the door read "Spiritualist Church".
+ As I stared at it, so out of place, I felt something rise up in me, a need to run, but an even stronger desire to go inside and offer myself in worship to... something.
+
+ That night, my dreams were filled with images of the church, the extreme wrongness of the road, and a chanting over and over of the words on the sign, "to the spiritualist church", until it burned itself into my mind.
+
+ We went out again, and, as the day before, the peculiar hut stood waiting, its black door calling out to me, demanding I go inside, scolding me was we passed it.
+ The village center was eerily quiet, and, as we turned onto that awful road, I noticed the paving shift and slide while staying the same, the cracks both moving and not moving, and I suppressed the urge to scream.
+ I heard the chanting inside my head once more, and felt the terrible draw of the church.
+ I knew I needed to leave immediately, back to safety, far away.
+ I told my host I had suddenly remembered an important appointment, pleading that I must go at once.
+
+ "Oh, but I haven't even shown you the hill.
+ Everyone who comes here eventually goes up the hill."
+
+ I hope this letter reaches you safely, and that you are not so foolish as to follow me.
+---
+{% extends 'templates/base.html' %}
+{% block body %}
+ <nav>
+ <a href='/writings'>&gt; writings</a>
+ </nav>
+ <header>
+ <h1>{{ title }}</h1>
+ </header>
+
+ <article>{{ body | markdown }}</article>
+{% endblock %}
diff --git a/src/writings/helena.thrust b/src/writings/helena.thrust
new file mode 100644
index 0000000..4649c45
--- /dev/null
+++ b/src/writings/helena.thrust
@@ -0,0 +1,49 @@
+---
+layout: writings
+title: Helena
+date: 2016-02-18
+notes: |
+ Another homework from my writing group. We had done an exercise about character
+ creation, and the stimulus was to write a story with that character.
+body: |
+ There once was a small village near the Western forests. Not much happened there, and the villagers preferred it that way. Among the villagers, however, was a woman called Helena. Helena was not so content with their simple way of life. She dreamed of royal palaces of marble, of gleaming spires atop castles, of cities bursting with activity. Most of all she dreamed of a prince who would come to whisk her away.
+
+ "Soon my prince will come!", she would say to anyone who would listen. "He will come and take me from this village and we shall be together forever!"
+
+ The other villagers would scoff and go back to their fields.
+
+
+ One day, a young man came to the village, looking to take a wife. He came upon Helena and, taken by her beauty, asked her hand in marriage.
+
+ "Oh dear Helena! Won't you be mine!"
+
+ "You are very kind", she replied, "but I am waiting for my prince to take me away".
+
+ He tried again and again, with gifts and serenades, but still she would only say that she was waiting for her prince. Saddened, he left the village and continued his journey.
+
+
+ A year later, another man tried, to much the same. And another, and another. No matter how hard they tried, they always got the same reply, "but I am waiting for my prince to take me away".
+
+ The women she'd grown up with tutted and sighed, as one by one they were married, until only Helena remained.
+
+
+ Winters came and summers passed. Youngsters became adults, adults became elders. Those she knew moved away to the city she so craved. Soon there was no-one but her. And still she waited.
+
+ "Soon my prince will come!" she would cry, alone, to herself.
+
+
+ A child out berry-picking came across her washed up downstream. She was smiling. Her prince had come, riding in on his pale horse, and at last taken her from the village.
+---
+{% extends 'templates/base.html' %}
+{% block body %}
+ <nav>
+ <a href='/writings'>&gt; writings</a>
+ </nav>
+ <header>
+ <h1>{{ title }}</h1>
+ </header>
+
+ <article>{{ body | markdown }}</article>
+ <hr />
+ <aside>{{ notes | markdown }}</aside>
+{% endblock %}
diff --git a/src/writings/piracy.thrust b/src/writings/piracy.thrust
new file mode 100644
index 0000000..66fcc1f
--- /dev/null
+++ b/src/writings/piracy.thrust
@@ -0,0 +1,63 @@
+---
+layout: writings
+title: Piracy
+date: 2016-04-21
+notes: |
+ Revision 1 the splicing together of two stories I'd already started. I think
+ this will be my first attempt at something longer.
+body: |
+ “The enemy!”. The cry carried down the halls, as people woke. The word had come
+ down from the surveillance balloon, and now the garrison was mobilizing.
+ Andreas was disappointed at the lack of sleep, but excited to see his first
+ battle. He ran out from his bunk and to his post. The strong flow of people
+ impeded him some, but before long he was looking at the ammunition dump of a
+ large gun. The crew fully assembled, he and another new recruit loaded the
+ shell, the leader aimed, and with a spark and a bang it flew toward the enemy.
+ He had read in the newspapers as a child of the intensity of war work, but he
+ was taken aback still. He hoped he had the luck to survive this first combat.
+
+ ***
+
+ Ugh, nope. Morrisee Williams had never gotten the hang of jacking as a guy. Too
+ much movement. Her sister, 8 years younger, had grown up with the tech and
+ could handle it just fine. Her parents couldn’t jack properly at all; a simple
+ simulated walk and they would need a lie-down.
+
+ She checked the screen: 9 hours and 48 minutes. Barely 10 minutes wasted. She
+ called the attendant. “A scotch on the rocks!”. She knew it was terrible whisky
+ (Leanne had ensured her daughter had a proper upbringing), but the thrill of
+ being able to order it herself more than made up. She flicked through the other
+ jacks she had brought. Do Or Die, another action-y thing about the war in
+ Afghanistan. Between Us, a sappy romance. Her Name In Lights, some period drama
+ about the 1990s. There was the one Jaime had given her, but she was embarrassed
+ to try it in public; involuntary movements and expressions were a risk with
+ jacking, and her parents were just across the aisle.
+
+ 9 hours and 40 minutes. Some crackers. The in-flight jacks were all weaksauce
+ for her parents generation, but one caught her eye.
+
+ Piracy.
+ In a world where anything can be copied, the only thing left to steal is people.
+ Tagged: scifi, female protag, deep immersion.
+
+ She leaned back and let the machine take her in.
+
+ ***
+
+ "Ooh, this one looks juicy!". Three professors, a dignitary, a general. Onto
+ the pile it went. Van idly fondled a peanut as she skimmed the day's departure
+ list…
+---
+{% extends 'templates/base.html' %}
+{% block body %}
+ <nav>
+ <a href='/writings'>&gt; writings</a>
+ </nav>
+ <header>
+ <h1>{{ title }}</h1>
+ </header>
+
+ <article>{{ body | markdown }}</article>
+ <hr />
+ <aside>{{ notes | markdown }}</aside>
+{% endblock %}
diff --git a/src/yubikey.thrust b/src/yubikey.thrust
new file mode 100644
index 0000000..e91143d
--- /dev/null
+++ b/src/yubikey.thrust
@@ -0,0 +1,135 @@
+---
+title: YubiKey
+date: 2020-05-14
+---
+{% extends 'templates/base.html' %}
+{% block body %}
+ <nav>
+ <a href='/projects'>&gt; projects</a>
+ </nav>
+
+ <header>
+ <h1>{{ title }}</h1>
+ </header>
+
+ <article>
+ {% markdown %}
+ This article contains tips & tricks for using [YubiKeys](https://www.yubico.com) for authentication.
+
+ [TOC]
+
+ ## `sudo` over SSH using YubiKey
+
+ Remembering root passwords for machines is annoying and error-prone.
+ You can reuse the same passwords and be insecure.
+ You can use different passwords and forget them.
+ You can type them into IRC and then have to reset them.
+ But there is a better way.
+
+ In modern Linux & Unix systems, [Pluggable Authentication Modules (PAM)](https://en.wikipedia.org/wiki/Pluggable_authentication_module) can provide multiple ways of authenticating a user.
+ The most common one is "asking for a password", but they can also query external network services or require a second factor.
+ The module `pam_ssh_agent_auth` authenticates users by checking against SSH keys from a connected SSH agent, which means that we can have passwordless `sudo` when logged in remotely.
+
+ Using `pam_ssh_agent_auth` with a YubiKey is both more convenient and more secure than passwords: you only need to tap the YubiKey, and because you _need_ to tap the YubiKey, becoming root requires a physical action on your part.
+
+ NB: Technically this allows all SSH keys loaded into the SSH Agent to be used to become root.
+ If you have a mixture of YubiKey keys and on-disk SSH keys, I would recommend using `ssh-askpass` / `SSH_ASKPASS` to require confirmation.
+
+ ### Debian & Ubuntu
+
+ TL;DR:
+
+ - Install `pam_ssh_agent_auth`.
+ - Enable `pam_ssh_agent_auth` for use with `sudo`.
+ - Allow `sudo` to maintain access to `SSH_AUTH_SOCK`.
+ - Connect to our remote machine with _SSH agent forwarding_ and never have to remember a server password again.
+
+ On your remote machine, first install the SSH agent authentication PAM:
+
+ ```sh
+ $ apt install libpam-ssh-agent-auth
+ ```
+
+ Then, enable and configure the PAM module for use with `sudo`:
+
+ ```sh
+ $ cat /etc/pam.d/sudo
+ #%PAM-1.0
+
+ # Allow users to use their regular authorized SSH keys for sudo,
+ # and allow them to manage the keys themselves.
+ auth sufficient pam_ssh_agent_auth.so file=~/.ssh/authorized_keys allow_user_owned_authorized_keys_file
+
+ # # Alternatively, have a single central key file, owned by root.
+ # # This is useful if you only want a subset of SSH keys to grant root permissions.
+ # auth sufficient pam_ssh_agent_auth.so file=/etc/ssh/sudo_authorized_keys
+
+ @include common-auth
+ @include common-account
+ @include common-session-noninteractive
+ ```
+
+ Allow `sudo` to maintain access to the SSH agent by keeping `SSH_AUTH_SOCK`:
+
+ ```sh
+ $ cat /etc/sudoers
+ Defaults env_keep += SSH_AUTH_SOCK
+ Defaults env_reset
+ Defaults mail_badpass
+ Defaults secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
+
+ # User privilege specification
+ root ALL=(ALL:ALL) ALL
+
+ # Allow members of group sudo to execute any command
+ %sudo ALL=(ALL:ALL) ALL
+ ```
+
+ To test it, connect to the machine with `ssh -A server.example.com`, and try using `sudo`: it should _not_ ask for a password, and the YubiKey should flash for a tap.
+
+ For convenience, add `ForwardAgent yes` to the relevant hosts your SSH config to set `-A` by default:
+
+ ```sh
+ $ cat ~/.ssh/config
+ Host server.example.com
+ ForwardAgent yes
+
+ Host 192.168.16.*
+ ForwardAgent yes
+ ```
+
+ ### NixOS
+
+ On [NixOS](https://nixos.org), the configuration for the server is much simpler:
+
+ ```sh
+ $ cat /etc/nixos/configuration.nix
+ { config, pkgs, ... }:
+ {
+ . . .
+ security.pam.enableSSHAgentAuth = true;
+ security.pam.services.sudo.sshAgentAuth = true;
+
+ users.users.eth.openssh.authorizedKeys.keys = [
+ "ssh-rsa . . .",
+ "ssh-rsa . . .",
+ ];
+ }
+ ```
+
+ To test it, connect to the machine with `ssh -A server.example.com`, and try using `sudo`: it should _not_ ask for a password, and the YubiKey should flash for a tap.
+
+ For convenience, add `ForwardAgent yes` to the relevant hosts your SSH config to set `-A` by default:
+
+ ```sh
+ $ cat ~/.ssh/config
+ Host server.example.com
+ ForwardAgent yes
+
+ Host 192.168.16.*
+ ForwardAgent yes
+ ```
+
+ {% endmarkdown %}
+ </article>
+{% endblock %}
diff --git a/templates/base.html b/templates/base.html
new file mode 100644
index 0000000..1380cb6
--- /dev/null
+++ b/templates/base.html
@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html lang='en'>
+<head>
+ <meta charset='utf-8'>
+ <meta name='viewport' content='width=device-width, initial-scale=1'>
+
+ <title>{{ title }}</title>
+ <style>
+ {% include 'templates/css' %}
+ </style>
+</head>
+
+<body>
+{% block body %}
+{% endblock %}
+</body>
+</html>
diff --git a/templates/css b/templates/css
new file mode 100644
index 0000000..7923edf
--- /dev/null
+++ b/templates/css
@@ -0,0 +1,128 @@
+:root {
+ --background-color: hsl(0, 0%, 100%);
+ --callout-color: hsl(0, 0%, 90%);
+
+ --foreground-color: hsl(0, 0%, 0%);
+ --link-color: hsl(0, 0%, 50%);
+ --link-hover-color: hsl(205, 69%, 50%);
+}
+@media (prefers-color-scheme: dark) {
+ :root {
+ --background-color: hsl(0, 0%, 12%);
+ --callout-color: hsl(0, 0%, 24%);
+
+ --foreground-color: hsl(0, 0%, 82%);
+ --link-color: hsl(0, 0%, 65%);
+ --link-hover-color: hsl(259, 49%, 65%);
+ }
+}
+
+blockquote {
+ border-left: 1px solid var(--foreground-color);
+ padding-left: 1em;
+ margin-left: 1em;
+}
+
+body {
+ text-align: left;
+ font-size: 14pt;
+ font-family: sans-serif;
+ font-weight: lighter;
+ min-width: 12em;
+ padding: 0.5em;
+
+ background: var(--background-color);
+ color: var(--foreground-color);
+}
+pre, table {
+ font-weight: lighter;
+ font-size: 0.8em;
+
+ background: var(--callout-color);
+ border-radius: 5px;
+ border: solid 3px var(--callout-color);
+
+ overflow-x: scroll;
+}
+details {
+ background: var(--callout-color);
+ border-radius: 5px;
+ border: solid 3px var(--callout-color);
+}
+
+@media screen and ( min-width: 40em ) {
+ body {
+ margin: 0 auto;
+ padding: 10% 0;
+ max-width: 30em;
+ }
+}
+
+h1, h2, h3 {
+ text-transform: uppercase;
+ font-weight: lighter;
+}
+
+h1 {
+ font-size: 1.2em;
+ margin-bottom: 0.5em;
+}
+h2 {
+ font-size: 1em;
+ margin-bottom: 0.5em;
+}
+h3 {
+ font-size: 0.9em;
+ margin-bottom: 0.2em
+}
+
+header h1 {
+ margin-bottom: 0.2em;
+}
+header p {
+ font-size: 0.9em;
+ margin-top: 0;
+ margin-bottom: 0.2em;
+}
+
+p, li {
+ line-height: 1.4;
+}
+
+ul {
+ list-style-type: circle;
+}
+
+a, a:visited {
+ color: var(--link-color);
+}
+a:hover {
+ color: var(--link-hover-color);
+}
+
+nav ul {
+ list-style: none;
+ padding-left: 0;
+}
+nav a, a:visited {
+ text-decoration: none;
+}
+
+span.non-breaking {
+ white-space: nowrap;
+}
+
+svg {
+ max-width: 100%;
+}
+svg circle, ellipse, polygon {
+ stroke: var(--foreground-color);
+ fill: var(--background-color);
+}
+svg path, polyline {
+ stroke: var(--foreground-color);
+ fill: none;
+}
+svg text {
+ fill: var(--foreground-color);
+}
diff --git a/thrust b/thrust
new file mode 100755
index 0000000..d30d9bd
--- /dev/null
+++ b/thrust
@@ -0,0 +1,133 @@
+#!/usr/bin/env python3
+"""Thrust is a static site generator."""
+
+import sys
+import textwrap
+
+from typing import Any, Dict
+
+import jinja2
+import jinja2.ext
+import jinja2.nodes
+import markdown
+import yaml
+
+
+# cribbed from https://jinja.palletsprojects.com/en/2.11.x/extensions/#cache.
+class MarkdownExtension(jinja2.ext.Extension):
+ """MarkdownExtension provides {% markdown %} and {% endmarkdown %}."""
+ # a set of names that trigger the extension.
+ tags = {'markdown'}
+
+ def __init__(self, environment):
+ super(MarkdownExtension, self).__init__(environment)
+
+ # add the defaults to the environment
+ environment.extend(markdown=markdown.Markdown())
+
+ def parse(self, parser):
+ # the first token is the token that started the tag.
+ # we only listen to `markdown`, so it will always be that.
+ # we keep the line number for the nodes we create later on.
+ lineno = next(parser.stream).lineno
+
+ # parse the body of the `{% markdown %}` up to `{% endmarkdown %}`.
+ body = parser.parse_statements(['name:endmarkdown'], drop_needle=True)
+
+ # return a `CallBlock` node that calls self._markdown_support.
+ return jinja2.nodes.CallBlock(
+ self.call_method('_markdown_support'), [], [], body).set_lineno(lineno)
+
+ def _markdown_support(self, caller):
+ """Helper callback."""
+ block = caller()
+ block = textwrap.dedent(block)
+ return self.environment.markdown.convert(block)
+
+
+class Thrust:
+ """Thrust is a document templater."""
+
+ def __init__(self, template: str, data: Dict[str, Any]):
+ self.template = template
+ self.data = data
+
+ @classmethod
+ def from_path(cls, path: str) -> 'Thrust':
+ with open(path) as f:
+ return cls.parse(f.read())
+
+ @classmethod
+ def parse(cls, src: str) -> 'Thrust':
+ """Parse a source file to maybe return a valid Thrust object."""
+ lines = src.split('\n')
+
+ parts = []
+ for line in lines:
+ if line == '---':
+ parts.append([])
+ else:
+ parts[-1].append(line)
+ if len(parts) != 2:
+ raise ValueError(
+ f'file needs 2 "---" deliniated parts, found {len(parts)}')
+
+ data = yaml.safe_load('\n'.join(parts[0]).replace('\t', ' '))
+ if data is None:
+ data = {}
+
+ template = '\n'.join(parts[1])
+ return cls(template, data)
+
+ @property
+ def _environment(self) -> jinja2.Environment:
+ environment = jinja2.Environment(
+ loader=jinja2.FileSystemLoader('.'),
+ extensions=[MarkdownExtension],
+ trim_blocks=True,
+ )
+ environment.markdown = markdown.Markdown(
+ extensions=['attr_list', 'extra', 'toc'])
+
+ def loadthrust_filter(path: str) -> str:
+ return Thrust.from_path(path)
+ environment.filters['loadthrust'] = loadthrust_filter
+
+ def markdown_filter(src: str) -> str:
+ return environment.markdown.convert(src)
+ environment.filters['markdown'] = markdown_filter
+
+ def template_filter(tmpl: str) -> str:
+ return environment.from_string(tmpl).render(self.data)
+ environment.filters['template'] = template_filter
+
+ def prefix_filter(s: str, prefix: str, first=True) -> str:
+ if not first:
+ lines = s.splitlines()
+ return '\n'.join(lines[0:1] + [f'{prefix}{l}' for l in lines[1:]])
+ return '\n'.join([f'{prefix}{l}' for l in s.splitlines()])
+ environment.filters['prefix'] = prefix_filter
+
+ return environment
+
+ def render(self) -> str:
+ """Render the data in the template."""
+ return self._environment.from_string(self.template).render(self.data)
+
+
+if __name__ == '__main__':
+ if len(sys.argv) != 2:
+ print(f'usage: {sys.argv[0]} <in-path>')
+ sys.exit(1)
+
+ in_path = sys.argv[1]
+
+ try:
+ thrust = Thrust.from_path(in_path)
+ print(thrust.render())
+ except ValueError as error:
+ print(f'{in_path}: {error}', file=sys.stderr)
+ sys.exit(1)
+ except jinja2.exceptions.TemplateError as error:
+ print(error, file=sys.stderr)
+ sys.exit(1)