aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCullum Smith <cullum@sacredheartsc.com>2024-07-03 12:25:01 -0400
committerCullum Smith <cullum@sacredheartsc.com>2024-07-03 12:25:01 -0400
commit56a863f9f11340a9310907e4131b9dc7483df623 (patch)
treec2ea33489c2150bafd74f04e5c9af483f7c2fbf9
downloadwebsite-56a863f9f11340a9310907e4131b9dc7483df623.tar.gz
initial commit
-rw-r--r--.gitignore5
-rw-r--r--LICENSE24
-rw-r--r--Makefile128
-rw-r--r--README.md56
-rw-r--r--defaults.yaml9
-rw-r--r--requirements.txt2
-rwxr-xr-xscripts/bloglist.py26
-rw-r--r--scripts/common.py53
-rwxr-xr-xscripts/rss.py48
-rw-r--r--src/android-chrome-192x192.pngbin0 -> 22287 bytes
-rw-r--r--src/apple-touch-icon.pngbin0 -> 20142 bytes
-rw-r--r--src/blog/building-a-personal-voip-system/index.md991
-rw-r--r--src/blog/desktop-linux-with-nfs-homedirs/index.md226
-rw-r--r--src/blog/index.md9
-rw-r--r--src/blog/makefile-based-blogging/index.md249
-rw-r--r--src/blog/reevaluating-rhel/index.md182
-rw-r--r--src/browserconfig.xml9
-rw-r--r--src/css/style.css105
-rw-r--r--src/cv/index.md106
-rw-r--r--src/favicon-16x16.pngbin0 -> 1230 bytes
-rw-r--r--src/favicon-32x32.pngbin0 -> 1989 bytes
-rw-r--r--src/favicon.icobin0 -> 15086 bytes
-rw-r--r--src/gpg.asc52
-rw-r--r--src/index.md40
-rw-r--r--src/me.jpgbin0 -> 12054 bytes
-rw-r--r--src/mstile-150x150.pngbin0 -> 11220 bytes
-rw-r--r--src/robots.txt2
-rw-r--r--src/safari-pinned-tab.svg89
-rw-r--r--src/site.webmanifest14
-rw-r--r--templates/cv.html88
-rw-r--r--templates/default.html88
31 files changed, 2601 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0e25b32
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+public
+**/.bloglist.md
+*.pyc
+*.pyo
+*.swp
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..57c0367
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,24 @@
+BSD 2-Clause License
+
+Copyright (c) 2024, Cullum Smith
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..d079cf8
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,128 @@
+### CHANGE ME ######################
+DOMAIN = www.sacredheartsc.com
+URL = https://${DOMAIN}
+RSYNC_TARGET = ${DOMAIN}:/var/www/${DOMAIN}
+FEED_TITLE = Cullum Smith's Blog
+FEED_DESCRIPTION = Dad, southerner, unix wrangler, banjo enjoyer
+STATIC_REGEX = .*\.(html|css|jpg|jpeg|png|ico|xml|txt|asc|webmanifest)
+RECENT_POSTS_LIMIT = 5
+
+
+### VARIABLES ######################
+SOURCE_DIR = src
+OUTPUT_DIR = public
+SCRIPT_DIR = scripts
+
+BLOG_DIR = blog
+
+TEMPLATE = templates/default.html
+CV_TEMPLATE = templates/cv.html
+DEFAULTS = defaults.yaml
+
+BLOG_LIST_SCRIPT = ${SCRIPT_DIR}/bloglist.py
+BLOG_LIST_REPLACE = __BLOG_LIST__
+BLOG_LIST_FILE = .bloglist.md
+
+BLOG_RSS_SCRIPT = ${SCRIPT_DIR}/rss.py
+BLOG_RSS_FILE = ${BLOG_DIR}/feed.xml
+
+SOURCE_DIRS != find ${SOURCE_DIR} -mindepth 1 -type d
+SOURCE_HOMEPAGE := ${SOURCE_DIR}/index.md
+SOURCE_BLOGLIST := ${SOURCE_DIR}/${BLOG_DIR}/index.md
+SOURCE_CV := ${SOURCE_DIR}/cv/index.md
+SOURCE_SPECIAL := ${SOURCE_HOMEPAGE} ${SOURCE_BLOGLIST} ${SOURCE_CV}
+SOURCE_MARKDOWN != find ${SOURCE_DIR} -type f -name '*.md' ! -name ${BLOG_LIST_FILE} ${SOURCE_SPECIAL:C/^/! -path /}
+SOURCE_STATIC != find -E ${SOURCE_DIR} -type f -iregex ${STATIC_REGEX:Q}
+
+BLOG_POSTS != find ${SOURCE_DIR}/${BLOG_DIR} -type f -name '*.md' ! -name ${BLOG_LIST_FILE} ! -path ${SOURCE_DIR}/${BLOG_DIR}/index.md
+RECENT_POST_LIST = ${SOURCE_DIR}/${BLOG_LIST_FILE}
+FULL_POST_LIST = ${SOURCE_DIR}/${BLOG_DIR}/${BLOG_LIST_FILE}
+
+SOURCE2OUTPUT = S/^${SOURCE_DIR}\//${OUTPUT_DIR}\//
+SOURCE2HTML = ${SOURCE2OUTPUT}:S/.md$$/.html/
+OUTPUT2SOURCE = S/^${OUTPUT_DIR}\//${SOURCE_DIR}\//
+HTML2SOURCE = ${OUTPUT2SOURCE}:S/.html$$/.md/
+
+OUTPUT_DIRS := ${SOURCE_DIRS:${SOURCE2OUTPUT}}
+OUTPUT_HOMEPAGE := ${SOURCE_HOMEPAGE:${SOURCE2HTML}}
+OUTPUT_BLOGLIST := ${SOURCE_BLOGLIST:${SOURCE2HTML}}
+OUTPUT_CV := ${SOURCE_CV:${SOURCE2HTML}}
+OUTPUT_SPECIAL := ${SOURCE_SPECIAL:${SOURCE2HTML}}
+OUTPUT_MARKDOWN := ${SOURCE_MARKDOWN:${SOURCE2HTML}}
+OUTPUT_STATIC := ${SOURCE_STATIC:${SOURCE2OUTPUT}}
+OUTPUT_RSS := ${OUTPUT_DIR}/${BLOG_RSS_FILE}
+
+
+### BUILD COMMANDS ######################
+.SHELL: name=sh quiet="set -" echo="set -v" filter="set -" hasErrCtl=yes check="set -eo pipefail" ignore="set +e" echoFlag=v errFlag=e path=/bin/sh
+
+COPY = cp -p
+
+PANDOC := pandoc \
+ --highlight-style=kate \
+ --metadata=feed:/${BLOG_RSS_FILE} \
+ --defaults=${DEFAULTS}
+
+GENERATE_RSS := ${BLOG_RSS_SCRIPT} \
+ ${SOURCE_DIR}/${BLOG_DIR} \
+ --title=${FEED_TITLE:Q} \
+ --description=${FEED_DESCRIPTION:Q} \
+ --url=${URL:Q} \
+ --blog-path=/${BLOG_DIR} \
+ --feed-path=/${BLOG_RSS_FILE}
+
+GENERATE_BLOGLIST := ${BLOG_LIST_SCRIPT} ${SOURCE_DIR}/${BLOG_DIR}
+INTERPOLATE_BLOGLIST = sed -e '/${BLOG_LIST_REPLACE}/{r ${BLOGLIST_HTML}' -e 'd;}'
+
+
+### TARGETS ######################
+public: ${OUTPUT_DIRS} ${OUTPUT_SPECIAL} ${OUTPUT_MARKDOWN} ${OUTPUT_STATIC} ${OUTPUT_RSS}
+
+${OUTPUT_DIRS}:
+ mkdir -p $@
+
+# Homepage
+${OUTPUT_HOMEPAGE}: ${SOURCE_HOMEPAGE} ${RECENT_POST_LIST} ${TEMPLATE} BLOGLIST_HTML=${RECENT_POST_LIST}
+ ${INTERPOLATE_BLOGLIST} ${SOURCE_HOMEPAGE} | ${PANDOC} --template=${TEMPLATE} --output=$@
+
+# HTML for partial blog listing
+${RECENT_POST_LIST}: ${BLOG_POSTS} ${BLOG_LIST_SCRIPT}
+ ${GENERATE_BLOGLIST} ${RECENT_POSTS_LIMIT} > $@
+
+# CV
+${OUTPUT_CV}: ${SOURCE_CV} ${CV_TEMPLATE}
+ ${PANDOC} --template=${CV_TEMPLATE} --output=$@ ${SOURCE_CV}
+
+# Main blog page
+${OUTPUT_BLOGLIST}: ${SOURCE_BLOGLIST} ${FULL_POST_LIST} ${TEMPLATE} BLOGLIST_HTML=${FULL_POST_LIST}
+ ${INTERPOLATE_BLOGLIST} ${SOURCE_BLOGLIST} | ${PANDOC} --template=${TEMPLATE} --output=$@
+
+# HTML for full blog listing
+${FULL_POST_LIST}: ${BLOG_POSTS} ${BLOG_LIST_SCRIPT}
+ ${GENERATE_BLOGLIST} > $@
+
+# RSS feed
+${OUTPUT_RSS}: ${BLOG_POSTS} ${BLOG_RSS_SCRIPT}
+ ${GENERATE_RSS} > $@
+
+# Blog posts
+${OUTPUT_MARKDOWN}: ${@:${HTML2SOURCE}} ${TEMPLATE}
+ ${PANDOC} --template=${TEMPLATE} --output=$@ ${@:${HTML2SOURCE}}
+
+# Static assets
+${OUTPUT_STATIC}: ${@:${OUTPUT2SOURCE}}
+ ${COPY} ${@:${OUTPUT2SOURCE}} $@
+
+.PHONY: install serve rsync clean
+install:
+ pip install -r requirements.txt
+
+serve: public
+ cd ${OUTPUT_DIR} && python3 -m http.server
+
+rsync: public
+ rsync -rlphv --delete ${OUTPUT_DIR}/ ${RSYNC_TARGET}
+
+clean:
+ rm -rf ${OUTPUT_DIR}
+ find ${SOURCE_DIR} -type f -name ${BLOG_LIST_FILE} -delete
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..d857e87
--- /dev/null
+++ b/README.md
@@ -0,0 +1,56 @@
+[www.sacredheartsc.com](https://www.sacredheartsc.com/)
+=========================
+
+This repository contains the source for [www.sacredheartsc.com](https://www.sacredheartsc.com).
+It consists of Markdown files and a custom static site generator build using pandoc and BSD `make(1)`.
+
+# Requirements
+
+- coreutils
+- python3
+- pandoc
+- BSD make
+
+# Instructions
+
+First, install the `pip` requirements:
+
+ make install
+
+You'll want to edit the [Makefile](Makefile) to set your site URL,
+RSS feed title, etc.
+
+Then, start writing markdown documents in the `src` directory. You can use
+whatever naming convention and directory structure you like. Files ending in
+`.md` will be converted to `.html` with the same path.
+
+The `src/blog` directory is special. Markdown files in this directory are
+used to populate the front-page blog listing in [index.md](src/index.md).
+Before pandoc converts this file to HTML, the special string `__BLOG_LIST__`
+is replaced with the output of [bloglist.py](scripts/bloglist.py).
+This Python script produces a date-sorted markdown list of all your blog posts.
+
+Each markdown file can have YAML frontmatter with the following metadata:
+
+ ---
+ title: A boring blog post
+ date: YYYY-MM-DD
+ subtitle: an optional subtitle
+ heading: optional, if you want the first <h1> to be different from <title>
+ description: optional, short description for <head> and the blog listing
+ draft: if set, hides the post from the blog listing
+ ---
+
+You can change the resulting HTML by modifying the [template](templates/default.html).
+Changing the format of the blog listing requires modifying the Python script.
+
+Build the website by using the default target:
+
+ make
+
+This will create a directory called `public` containing all your markdown files
+rendered to HTML.
+
+You also can run a local webserver, which listens on port 8000, using:
+
+ make serve
diff --git a/defaults.yaml b/defaults.yaml
new file mode 100644
index 0000000..c628134
--- /dev/null
+++ b/defaults.yaml
@@ -0,0 +1,9 @@
+from: markdown+fenced_divs+bracketed_spans+smart
+to: html5
+standalone: true
+html-q-tags: true
+
+css:
+ - /css/style.css
+
+metadata:
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..ea85e26
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,2 @@
+python-frontmatter
+dateparser
diff --git a/scripts/bloglist.py b/scripts/bloglist.py
new file mode 100755
index 0000000..9e10354
--- /dev/null
+++ b/scripts/bloglist.py
@@ -0,0 +1,26 @@
+#!/usr/bin/env python3
+
+import argparse
+from common import get_blog_posts
+
+DATE_FORMAT = '%Y-%m-%d'
+
+parser = argparse.ArgumentParser('bloglist')
+parser.add_argument('BLOG_DIR', type=str, help='Directory containing markdown blog posts')
+parser.add_argument('LIMIT', nargs='?', default=None, type=int, help='Maximum number of posts to show')
+args = parser.parse_args()
+
+posts = get_blog_posts(args.BLOG_DIR)
+
+if args.LIMIT is not None:
+ posts = posts[0:args.LIMIT]
+
+if len(posts) == 0:
+ print('Nothing has been posted yet!')
+else:
+ for post in posts:
+ post_date = post['date'].strftime(DATE_FORMAT)
+
+ print(f'- [{post["title"]}]({post["href"]}) ({post_date})\n')
+ if post['description']:
+ print(f' {post["description"]}\n')
diff --git a/scripts/common.py b/scripts/common.py
new file mode 100644
index 0000000..9b23816
--- /dev/null
+++ b/scripts/common.py
@@ -0,0 +1,53 @@
+#!/usr/bin/env python3
+
+import os
+import frontmatter
+import dateparser
+import datetime
+from pathlib import Path
+
+BLOG_LIST_FILE = '.bloglist.md'
+
+def get_href(path):
+ path = Path(path)
+ path = path.relative_to(*path.parts[:1])
+ if path.name == 'index.md':
+ return '/{}/'.format(path.parent)
+ else:
+ return '/{}/{}.html'.format(path.parent, path.stem)
+
+def read_metadata(file):
+ post = frontmatter.load(file)
+
+ for field in ['title', 'date']:
+ if post.get(field) is None:
+ raise Exception("{} is missing metadata field '{}'".format(file, field))
+
+ if type(post['date']) not in [datetime.datetime, datetime.date]:
+ date = dateparser.parse(post['date'])
+ else:
+ date = post['date']
+
+ return {
+ 'title': post.get('title'),
+ 'date': date,
+ 'author': post.get('author'),
+ 'description': post.get('description'),
+ 'draft': post.get('draft'),
+ 'href': get_href(file)
+ }
+
+def get_blog_posts(blog_dir):
+ blog_index = os.path.join(blog_dir, 'index.md')
+ posts = []
+
+ for root, dirs, files in os.walk(blog_dir):
+ for file in files:
+ path = os.path.join(root, file)
+ if path.endswith('.md') and path != blog_index and file != BLOG_LIST_FILE:
+ metadata = read_metadata(path)
+ if not metadata['draft']:
+ posts.append(metadata)
+
+ posts.sort(key=lambda p: (p is None, p['date']), reverse=True)
+ return posts
diff --git a/scripts/rss.py b/scripts/rss.py
new file mode 100755
index 0000000..cf0cb9a
--- /dev/null
+++ b/scripts/rss.py
@@ -0,0 +1,48 @@
+#!/usr/bin/env python3
+
+import argparse
+import email.utils
+from datetime import datetime
+from common import get_blog_posts
+
+parser = argparse.ArgumentParser('rss')
+parser.add_argument('BLOG_DIR', type=str, help='Directory containing markdown blog posts')
+parser.add_argument('--limit', default=15, type=int, help='Maximum number of posts to show')
+parser.add_argument('--title', help='Feed title', required=True)
+parser.add_argument('--description', help='Feed description', required=True)
+parser.add_argument('--url', help='Root URL', required=True)
+parser.add_argument('--blog-path', help='Blog path', required=True)
+parser.add_argument('--feed-path', help='RSS feed path', required=True)
+args = parser.parse_args()
+
+posts = get_blog_posts(args.BLOG_DIR)
+posts = posts[0:args.limit]
+
+build_date = email.utils.format_datetime(datetime.now().astimezone())
+
+print(f'''<?xml version="1.0"?>
+<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
+<channel>
+ <title>{args.title}</title>
+ <link>{args.url}{args.blog_path}</link>
+ <language>en-US</language>
+ <description>{args.description}</description>
+ <lastBuildDate>{build_date}</lastBuildDate>
+ <atom:link href="{args.url}{args.feed_path}" rel="self" type="application/rss+xml"/>''')
+
+for post in posts:
+ pub_date = email.utils.format_datetime(post['date'].astimezone())
+
+ print(f''' <item>
+ <title>{post["title"]}</title>
+ <link>{args.url}{post["href"]}</link>
+ <guid>{args.url}{post["href"]}</guid>
+ <pubDate>{pub_date}</pubDate>''')
+
+ if 'description' in post:
+ print(f' <description>{post["description"]}</description>')
+
+ print(' </item>')
+
+print('</channel>')
+print('</rss>')
diff --git a/src/android-chrome-192x192.png b/src/android-chrome-192x192.png
new file mode 100644
index 0000000..f731faa
--- /dev/null
+++ b/src/android-chrome-192x192.png
Binary files differ
diff --git a/src/apple-touch-icon.png b/src/apple-touch-icon.png
new file mode 100644
index 0000000..0491547
--- /dev/null
+++ b/src/apple-touch-icon.png
Binary files differ
diff --git a/src/blog/building-a-personal-voip-system/index.md b/src/blog/building-a-personal-voip-system/index.md
new file mode 100644
index 0000000..4d32748
--- /dev/null
+++ b/src/blog/building-a-personal-voip-system/index.md
@@ -0,0 +1,991 @@
+---
+title: Building a Personal VoIP System
+date: May 25, 2023
+description: Take control of your telephony with Asterisk!
+---
+
+I've always been a big [self-hoster](https://github.com/sacredheartsc/selfhosted),
+but had never attempted anything related to VoIP. I recently purchased some IP
+phones and set up a personal home phone network using [Asterisk](https://www.asterisk.org/).
+This guide will help you set up your own digital telephone system using
+open-source tools.
+
+This guide is written for those who are experienced with self-hosting, but are
+totally unfamiliar with VoIP. Therefore, I'm going to gloss over certain
+uninteresting technicalities for the sake of brevity.
+
+
+## SIP: A Brief Introduction
+
+Before getting your hands dirty with phone configuration and Asterisk dialplans,
+it's worth taking some time to understand the underlying network protocols
+involved. This will pay dividends later on when you're debugging the inevitable
+connectivity issues with your voice calls (trust me).
+
+The [Session Initialization Protocol](https://www.ietf.org/rfc/rfc3261.txt), or
+SIP, is a signaling protocol used by basically every digital telephony device
+produced in the last two decades. VoIP phones, softphones, office conferencing
+devices...they all use SIP. SIP can use either TCP or UDP, and usually listens
+on port 5060.
+
+There are two really important things you need to know about about SIP.
+
+ 1. SIP does **not** carry the voice data. It's just a signaling protocol,
+ and it's only used to negotiate which IP address, port, and audio format
+ should be used to carry the audio stream.
+
+ 2. SIP was initially released in 1999, and was designed with the assumption
+ that each device has its own globally routable public IP address. After
+ all, the IPv6 standard was released back in 1995, and NAT would soon be a
+ thing of the past...right? Unforunately, this did not end up being the case.
+
+For example, let's say we have a standard home network with two VoIP phones in
+the local 192.168.1.0/24 subnet. Consider what happens when these two phones want
+to call each other. In plain English, the SIP handshake will go something like
+this:
+
+> Hello 192.168.1.6.<br>
+> I would like to place an audio call using the G.711 codec.<br>
+> Please send your audio frames to me at address 192.168.1.5, port 20000.
+
+> Hi there, 192.168.1.5!<br>
+> Sure, I support the G.711 codec.<br>
+> Please send your audio frames to me at address 192.168.1.6, port 30000.
+
+Each phone randomly chooses an unused UDP port on which to receive the other
+party's audio stream. After the SIP negotiation, they will both send voice data
+to each other using the [Real-Time Transport Protocol](https://www.rfc-editor.org/rfc/rfc3550),
+or RTP. Since they are both on the same local network, this works fine.
+
+### NAT Problems
+
+Now consider what happens when we call someone _outside_ of our local network.
+Let's say our router has a public IP of 203.0.113.8, and our friend Alice has
+address 198.51.100.7.
+
+> Hello 198.51.100.7. Is Alice there?<br>
+> I would like to place an audio call using the G.711 codec.<br>
+> Please send your audio frames to me at address 192.168.1.5, port 20000.
+
+> Hi there, "192.168.1.5". I see you have source IP 203.0.113.8...strange!<br>
+> Sure, I support the G.711 codec.<br>
+> Please send your audio frames to me at address 198.51.100.7, port 30000.
+
+Thanks to [network address translation](https://en.wikipedia.org/wiki/Network_address_translation),
+or NAT, we are able to make the SIP connection to Alice. Unfortunately, you
+will probably find that audio only works in one direction!
+
+We've got two problems. First, we've asked Alice to send her audio to our _local_
+IP address, which won't route over the Internet. Luckily, most devices are smart
+enough to use the source address of the incoming packet when these don't match up.
+So Alice's audio stream will probably end up at our router.
+
+But second, our router will still block all of her audio traffic! When it receives
+a UDP packet from Alice's audio stream, there's no stateful NAT connection to
+match it back to an internal client, so it's silently dropped. Sad!
+
+### NAT Solutions
+
+There are three ways to solve this problem.
+
+1. Give each of your SIP devices its own public IP (probably not feasible).
+
+2. Use a SIP [Application Layer Gateway](https://www.voip-info.org/routers-sip-alg/).
+ This is a horrible feature offered by some routers. Basically, it deep-packet-inspects
+ your SIP traffic, rewrites the headers, and creates port forwards on-the-fly
+ to make sure the inbound audio stream makes its way to your device.
+
+ SIP ALGs are a total hack and notoriously buggy. In addition, when you decide
+ to encrypt your SIP traffic using TLS, they quit working altogether.
+
+3. Configure a fixed RTP port range on each of your SIP devices, then create
+ static port forwarding rules on your router for each device. This is really
+ the only decent option.
+
+ Note that since we'll be using Asterisk, you also have the option of
+ "proxying" all your audio streams through the Asterisk box--then you only
+ have one port forward to set up (we'll get to that later).
+
+
+## Asterisk: What _is_ it?
+
+[Asterisk](https://www.asterisk.org/) is an open-source implementation of a
+[private branch exchange](https://en.wikipedia.org/wiki/Business_telephone_system#Private_branch_exchange),
+or PBX. If you're a VoIP novice like I was, this is probably meaningless to you.
+
+A PBX is the central brain of any private telephone network. The general idea is
+this: You plug a bunch of dumb telephones into one side of the PBX, and you plug
+one or more uplinks to the [PSTN](https://en.wikipedia.org/wiki/Public_switched_telephone_network "Public Switched Telephone Network")
+into the other side. The PBX lets the internal phones dial each other using
+private internal extensions, while multiplexing calls to and from the PSTN. It
+also handles advanced features like voicemail, call queues, hunt groups, and
+interactive menus for callers.
+
+In the old days, the PBX would be a huge box down in the server room with a
+rat's nest of of RJ-11 telephone cables sprawling out, connecting it to every
+single phone in the office. These monstrosities have since been replaced with
+software-based PBXes that talk SIP over standard Ethernet networks. Asterisk is
+the de-facto open source PBX.
+
+
+## Jargon Glossary
+
+Before we get started, you need to know some lingo. Asterisk is an _old_ project,
+with an unintuitive configuration. Hopefully this section will help you decipher
+some of the documentation and setup guides you find out there.
+
+Extensions
+: Technically, in Asterisk parlance, any dialed number is an extension. In
+ practice though, an _extension_ typically refers to a _local_ phone number
+ (like 6001) with an associated SIP account. While extensions can be
+ alphanumeric, virtually no one does this--most people use 3- or 4-digit
+ numbers.
+
+Queues
+: Asterisk [queues](https://www.voip-info.org/asterisk-call-queues/) are a
+ mechanism for managing incoming callers. You can configure which local
+ extensions should ring for each queue, and even play custom music while
+ callers are waiting.
+
+SIP Trunk
+: A "SIP Trunk" is just a fancy term for your upstream telephone provider. For
+ example, if you subscribe to a provider like [VOIP.ms](https://voip.ms/en/invite/MzU5Nzc4),
+ they'll give you a public phone number and a SIP server to connect to, along
+ with a username and password. This is your "SIP Trunk."
+
+DID
+: A DID, or "Direct Inward Dial," is the technical term for a public phone number.
+
+Contexts
+: In Asterisk, every call is assigned a _context_. The context is simply a
+ name of your choosing, and you specify it when you configure each extension
+ or SIP trunk. For example, I give all my internal phones a context called
+ `from-home`, and I give my SIP trunk a context called `from-pstn`.
+
+Dialplan
+: The Asterisk [dialplan](https://wiki.asterisk.org/wiki/display/AST/Dialplan)
+ is an arcane scripting language where you define what happens when you dial a
+ number or an incoming call is received. The dialplan is the glue between the
+ _extensions_ and _contexts_ described above. The syntax is fairly unintuitive,
+ but don't worry! We'll walk through it in a later section.
+
+Codecs
+: A codec is a type of digital encoding used for the audio stream over the
+ wire. Every VoIP device supports one or more codecs, and you need at least
+ one common codec with the other party to make calls. When no shared codecs
+ are supported, Asterisk has the ability to do man-in-the-middle transcoding.
+ <br>
+ [G.711](https://en.wikipedia.org/wiki/G.711) is basically the only codec with
+ universal support. It has two versions: `ulaw` (North America/Japan) and
+ `alaw` (everywhere else). I use a higher quality codec ([G.722](https://en.wikipedia.org/wiki/G.722))
+ for internal calls, and let Asterisk transcode down to G.711 when I call out
+ to the PSTN.
+
+BLF / Presence
+: The BLF, or busy lamp field, is a little LED on your VoIP phone that lights
+ up when one of your contacts is using their line. In Asterisk, this functionality
+ is enabled by something called a [presence subscription](https://wiki.asterisk.org/wiki/display/AST/Configuring+res_pjsip+for+Presence+Subscriptions).
+
+SIP / PJSIP
+: On some guides online, you will see references to the `chan_sip` and `chan_pjsip`
+ modules. `chan_sip` is the legacy Asterisk SIP implementation. You should be
+ using PJSIP for everything these days.
+
+
+## Step 1: Acquire an IP Phone
+
+First, you will need one or more VoIP phones. Any device that supports SIP
+calling should work fine with Asterisk, so this mostly comes down to personal
+preference.
+
+As a beginner, you'll probably want to select for ease of configuration. Most
+modern VoIP phones expose a friendly web GUI where you configure all your SIP
+accounts, ring settings, etc. I'd also recommend avoiding any devices that
+rely on proprietary setup tools.
+
+Personally, I've had zero problems with Yealink devices. I recommend the [T54W](https://www.yealink.com/en/product-detail/ip-phone-t54w)
+model for a desk phone, or the [W73P](https://www.yealink.com/en/product-detail/dect-phone-w73p)
+if you prefer a cordless model. You can buy these new at the usual online retailers,
+but eBay often has used models for sale if you want to save some money.
+
+If you don't want to buy a physical device, you can also use a softphone app
+like [Linphone](https://f-droid.org/en/packages/org.linphone/). I use it on my
+Android phone running [GrapheneOS](https://grapheneos.org/), and it works well
+with the right settings. I'll cover Linphone in a later section.
+
+
+## Step 2: Subscribe to a VoIP Provider
+
+Next, you need to subscribe to a VoIP service so you can make and receive calls
+over the PSTN. You'll often see people call this a "SIP Trunk."
+
+Basically, you pay a VoIP company a small monthly fee, usually $3-$5 per month
+for a single DID. In exchange, they give you a public telephone number, along
+with credentials for their SIP server. After configuring Asterisk to connect to
+that SIP server, you can make and receive calls via the PSTN with that number.
+
+I can personally recommend two providers.
+
+1. [VOIP.ms](https://voip.ms/en/invite/MzU5Nzc4) is an inexpensive provider with
+ servers in the USA, Canada, and western Europe. You can get "unlimited"
+ inbound calls with a single DID for about $5 a month, or less if you pay by the
+ minute.
+
+ VOIP.ms also supports call encryption via TLS/SRTP, which is nice.
+
+2. [JMP.chat](https://jmp.chat/) (USA/Canada only) is an XMPP-based service that
+ lets you make and receive voice calls and SMS/MMS messages using your
+ existing XMPP account. I especially like JMP because there's already tons of
+ high-quality native XMPP apps for both desktop and mobile devices. In
+ addition, their entire stack is open source.
+
+ While JMP is primarily focused on XMPP calling, they also provide you with
+ a SIP account that you can use with Asterisk.
+
+There are _tons_ of other VoIP providers out there, so shop around.
+
+
+## Step 3: Configure Asterisk
+
+Now you're ready to set up Asterisk. These instructions are written for RHEL-based
+distributions, but should be applicable to other Unixen.
+
+### Installation
+
+First, install Asterisk:
+
+```bash
+dnf install asterisk asterisk-pjsip asterisk-voicemail-plain
+```
+
+You'll also need the sound files. Some distributions include these with their
+Asterisk package (EPEL does not).
+
+```bash
+for codec in g722 g729 gsm sln16 ulaw wav; do
+ curl -sL "https://downloads.asterisk.org/pub/telephony/sounds/asterisk-core-sounds-en-${codec}-current.tar.gz" \
+ | tar xvz -C /usr/share/asterisk/sounds
+done
+```
+
+### Network Configuration
+
+Now we're ready to edit some config files. If you're behind a NAT (likely), you'll
+need to start by telling Asterisk about your local network. Edit `/etc/asterisk/pjsip.conf`
+and add some transport templates.
+
+```lisp
+; /etc/asterisk/pjsip.conf
+
+; This template contains default settings for all transports.
+[transport-defaults](!)
+type = transport
+bind = 0.0.0.0
+
+; For communication to any addresses within local_nets, Asterisk will not apply
+; NAT-related workarounds.
+local_net = 127.0.0.0/8
+local_net = 10.0.0.0/8
+local_net = 172.16.0.0/12
+local_net = 192.168.0.0/16
+
+; If you have a public static IP for your Asterisk server, set it here.
+external_media_address = 203.0.113.8
+external_signaling_address = 203.0.113.8
+
+
+; The following UDP and TCP transports will inherit from the defaults.
+[transport-udp](transport-defaults)
+protocol = udp
+
+[transport-tcp](transport-defaults)
+protocol = tcp
+```
+
+Remember our discussion about NAT and RTP audio streams above? We also need to
+set up a static port range for incoming UDP audio traffic. Choose a suitable
+range for your Asterisk server in `rtp.conf`. Then, set up port forwarding on
+your router/firewall to forward all incoming UDP traffic within that range to the
+internal IP of your Asterisk server.
+
+**If your Asterisk server is behind NAT and you forget to do this, you'll experience
+one-way audio in your phone calls.**
+
+```lisp
+; /etc/asterisk/rtp.conf
+
+[general]
+rtpstart=20000
+rtpend=20999
+```
+
+### SIP Trunks
+
+Next, we'll configure our SIP trunk(s). I'll be using `pjsip_wizard.conf`, since
+the PJSIP Wizard configuration style eliminates a ton of boilerplate.
+For this step, you'll need a server hostname and port, along with the username
+and password provided by your upstream SIP provider.
+
+```lisp
+; /etc/asterisk/pjsip_wizard.conf
+
+; This template contains default settings for all SIP trunks.
+[trunk-defaults](!)
+type = wizard
+
+; Require SIP authentication.
+sends_auth = yes
+
+; Require SIP registration.
+sends_registrations = yes
+
+; Send media to the address and port on the incoming packet, regardless of what
+; the SIP headers say (NAT workaround).
+endpoint/rtp_symmetric = yes
+
+; Rewrite the SIP contact to the address and port of the request (NAT workaround).
+endpoint/rewrite_contact = yes
+
+; Send the Remote-Party-ID SIP header. Some providers need this.
+endpoint/send_rpid = yes
+
+; Force the ulaw codec, which should work for everything in North America.
+endpoint/allow = !all,ulaw
+
+; Call encryption is out of scope for this guide.
+endpoint/media_encryption = no
+
+; If registration fails, keep trying ~forever.
+registration/max_retries = 4294967295
+
+; Don't assume an authentication failure is permanent.
+registration/auth_rejection_permanent = no
+
+; Perform a connectivity check every 30 seconds.
+aor/qualify_frequency = 30
+
+
+; Your SIP trunks go here. For this example, we'll assume you're using VOIP.ms.
+; You can pick whatever section name you like, as long as its unique.
+[voipms](trunk-defaults)
+; You almost certainly want to use TCP.
+transport = transport-tcp
+
+; Hostname and port for your SIP provider.
+remote_hosts = atlanta2.voip.ms:5060
+
+; Choose a context name for incoming calls from this account. You'll use this
+; name in your dialplan.
+endpoint/context = from-pstn
+
+; Your SIP provider will give you these credentials.
+outbound_auth/username = 555555
+outbound_auth/password = s3cret
+```
+
+### Local SIP Extensions
+
+In the same file, we'll also configure our local extensions. You'll need an
+extension for each VoIP handset or softphone within your network. I'll be
+using a prefix of `6XXX` for local extensions, but feel free to use whatever
+convention you prefer.
+
+The following example has three extensions: two dedicated VoIP
+handsets, and one Android softphone. Note that if you want
+to use a softphone outside of your local network, you'll need to either open
+your Asterisk instance to the world (not recommended, unless you know what you're doing)
+or set up some kind of VPN.
+
+```lisp
+; /etc/asterisk/pjsip_wizard.conf
+
+; This template contains default settings for all local extensions.
+[extension-defaults](!)
+type = wizard
+
+; Require clients to register.
+accepts_registrations = yes
+
+; Require clients to authenticate.
+accepts_auth = yes
+
+; When simultaneous logins from the same account exceed max_contacts, disconnect
+; the oldest session.
+aor/remove_existing = yes
+
+; For internal phones, allow the higher quality g722 codec in addition to ulaw.
+endpoint/allow = !all,g722,ulaw
+
+; Context name for BLF/presence subscriptions. This can be any string of your
+; choosing, but I'm using "subscribe". We'll use this in the dialplan later.
+endpoint/subscribe_context = subscribe
+
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+; Extension 6001 - VoIP phone
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+[6001](extension-defaults)
+; Dialplan context name for calls originating from this account.
+endpoint/context = from-home
+
+; Voicemail address (note: I'm using the same voicemail address for all extensions)
+endpoint/mailboxes = 6000@default
+
+; Internal Caller ID string for this device.
+endpoint/callerid = Living Room <6001>
+
+; Username for SIP account. By convention, this should be the extension number.
+inbound_auth/username = 6001
+
+; Password for SIP account (you can choose whatever password you like).
+inbound_auth/password = s3cret
+
+; Maximum number of simultaneous logins for this account.
+aor/max_contacts = 1
+
+; Check connectivity every 30 seconds.
+aor/qualify_frequency = 30
+
+; Set connectivity check timeout to 3 seconds.
+aor/qualify_timeout = 3.0
+
+; IMPORTANT! This setting determines whether the audio stream will be proxied
+; through the Asterisk server.
+;
+; If this device is directly reachable by the internet (either by a publicly
+; routable IP, or static port mappings on your router), choose YES.
+;
+; Otherwise, if this device is hidden behind NAT, choose NO.
+endpoint/direct_media = yes
+
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+; Extension 6002 - VoIP phone
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+; (Same settings as above, except for the extension number and password.)
+[6002](extension-defaults)
+endpoint/context = from-home
+endpoint/mailboxes = 6000@default
+endpoint/callerid = Kitchen <6002>
+inbound_auth/username = 6002
+inbound_auth/password = s3cret2
+aor/max_contacts = 1
+aor/qualify_frequency = 30
+aor/qualify_timeout = 3.0
+endpoint/direct_media = yes
+
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+; Extension 6003 - Linphone app on Android device
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+[6003](extension-defaults)
+endpoint/context = from-home
+endpoint/mailboxes = 6000@default
+endpoint/callerid = Smartphone <6003>
+inbound_auth/username = 6003
+inbound_auth/password = s3cret3
+aor/max_contacts = 1
+endpoint/direct_media = no
+
+; For mobile devices, connectivity checks should be disabled.
+; Two reasons for this:
+; 1. Poor cellular signal often causes the connectivity check to time out,
+; even when nothing is actually wrong.
+; 2. Frequent traffic on the TCP session causes constant wakeups and kills
+; battery life.
+aor/qualify_frequency = 0
+```
+
+### Voicemail
+
+Voicemail recordings are stored on the local filesystem of the Asterisk server.
+You can access them by dialing a special voicemail extension from a local phone
+(configured in your dialplan). In addition, if you have a local [MTA](https://en.wikipedia.org/wiki/Message_transfer_agent "Mail Transfer Agent")
+running, Asterisk can dispatch an email to an address of your choice whenever new
+voicemails arrive.
+
+In the previous section, we referenced a voicemail address called `6000@default`
+for our internal extensions. We'll create the actual voicemail box in `voicemail.conf`.
+
+```lisp
+; /etc/asterisk/voicemail.conf
+
+; This section contains global voicemail options.
+[general]
+; Audio formats to store voicemail files.
+format=wav49|gsm|wav
+
+; "From:" address used for sending voicemail emails.
+serveremail=asterisk-noreply@example.com
+
+; Whether to attach the voicemail audio file to notification emails.
+attach=yes
+
+; Maximum number of messages per mailbox.
+maxmsg=100
+
+; Maximum length of a voicemail message in seconds.
+maxsecs=300
+
+; Maximum length of a voicemail greeting in seconds.
+maxgreet=60
+
+; How many milliseconds to skip forward/back when rew/ff in message playback.
+skipms=3000
+
+; How many seconds of silence before we end the recording.
+maxsilence=10
+
+; Silence threshold (what we consider silence: the lower, the more sensitive).
+silencethreshold=128
+
+; Maximum number of failed login attempts.
+maxlogins=3
+
+; Email template for voicemail notifications.
+emailsubject=New voicemail ${VM_MSGNUM} in mailbox ${VM_MAILBOX}
+emailbody=Hi ${VM_NAME},\n\nYou have a new voicemail in mailbox ${VM_MAILBOX}.\n\nFrom: ${VM_CALLERID}\nDate: ${VM_DATE}\nDuration: ${VM_DUR}\nMessage Number: ${VM_MSGNUM}
+emaildateformat=%A, %B %d, %Y at %r
+
+; Default timezone for mailboxes (defined in zonemessages section below).
+tz=myzone
+
+; Locale for generating date/time strings.
+locale=en_US.UTF-8
+
+; Minimum voicemail password length.
+minpassword=4
+
+
+; Custom timezone definitions go in this section.
+[zonemessages]
+myzone=America/New_York|'vm-received' Q 'digits/at' IMp
+
+
+;;;;;;;;;;;;;;;;;
+; Voicemail Boxes
+;;;;;;;;;;;;;;;;;
+
+; Each of the following sections describe a voicemail "context," which is a
+; a collection of voicemail boxes. If you don't need multiple contexts, you
+; can specify "default".
+;
+; The format is as follows:
+; VOICEMAIL_NUMBER => INITIAL_PASSWORD,REAL_NAME,EMAIL_ADDRESS,,,
+;
+; The last three fields aren't relevant for simple configurations.
+
+[default]
+6000 => 1234,John Doe,johndoe@example.com,,,
+```
+
+### Queues
+
+When someone calls our phone number, we'd like all our phones to ring simultaneously
+(similar to a traditional landline phone). We can emulate this behavior in Asterisk
+using a `ringall` queue.
+
+Queues are configured in `queues.conf`.
+
+```lisp
+; /etc/asterisk/queues.conf
+
+; Global queue settings go in this section.
+[general]
+; Persist dynamic member lists in the astdb.
+persistentmembers = yes
+
+; Some options for more intuitive queue behavior.
+autofill = yes
+monitor-type = MixMonitor
+shared_lastcall = yes
+log_membername_as_agent = yes
+
+;;;;;;;;;;;;;;;;;;;
+; Queue Definitions
+;;;;;;;;;;;;;;;;;;;
+
+; The "home-phones" queue is for incoming calls to our home phone line.
+[home-phones]
+; For each incoming call, ring all members of the queue.
+strategy = ringall
+
+; Max number of seconds a caller waits in the queue.
+timeout = 30
+
+; Don't announce estimated hold time, etc.
+announce-frequency = 0
+announce-holdtime = no
+announce-position = no
+periodic-announce-frequency = 0
+
+; Allow ringing even when no queue members are present.
+joinempty = yes
+leavewhenempty = no
+
+; Ring member phones even when they are on another call.
+ringinuse = yes
+
+; Queue Members
+;
+; Each member is specified with the following format:
+; member => INTERFACE,PENALTY,FRIENDLY_NAME,PRESENCE_INTERFACE
+;
+; The "penalty" value is not interesting for our use case.
+; With PJSIP, the BLF/Presence interface is identical to the standard interface name.
+member => PJSIP/6001,0,Living Room,PJSIP/6001
+member => PJSIP/6002,0,Kitchen,PJSIP/6002
+member => PJSIP/6003,0,Smartphone,PJSIP/6003
+```
+
+### The Dialplan
+
+At this point, we've configured our upstream SIP trunks, created SIP accounts for
+some local phone extensions, and even defined a queue for incoming calls.
+All that's left is to glue all this together in a [dialplan](https://wiki.asterisk.org/wiki/display/AST/Dialplan)!
+
+The dialplan is configured in `extensions.conf`, and it's definitely the least intuitive
+aspect of Asterisk. You'll most likely find its syntax to be confusing at first glance.
+
+The thing to remember is that in the dialplan, everything is an _application_.
+Hanging up is performed by the `Hangup()` application, and voicemail prompts are handled
+by the `Voicemail()` application. Dialing a phone number is handled by (you guessed it)
+the `Dial()` application. Each application can take one or more comma-separated arguments.
+
+Now, for the syntax. Each dialplan context is marked by square brackets. Each
+line within the context is (confusingly) called an _extension_, and has the
+following format:
+
+```lisp
+[context-name]
+exten => NAME,PRIORITY,APPLICATION()
+exten => NAME,PRIORITY,APPLICATION()
+; etc...
+```
+
+The _name_ is the number (or pattern) of the extension.
+
+The _priority_ defines the order in which the step should be executed.
+
+Finally, the _application_ performs some action on the call.
+
+A simple context definition might look something like this:
+
+```lisp
+[from-home]
+; ${EXTEN} is a macro which expands to the currently dialed number.
+exten => _6XXX,1,Dial(PJSIP/${EXTEN})
+exten => _6XXX,2,Hangup()
+```
+
+This dialplan section allows internal phones to call each other.
+The `_6XXX` is an extension pattern. When a device in the `from-home`
+context dials a 4-digit extension beginning with `6`, Asterisk will
+ring the corresponding PJSIP account.
+
+
+Because it gets tedious repeating the same extension name over and over,
+Asterisk provides some syntactic sugar. The exact same dialplan could also
+be written as the following:
+
+```lisp
+[from-home]
+exten => _6XXX,1,Dial(PJSIP/${EXTEN})
+ same => n,Hangup()
+```
+
+Below, I've provided a complete Asterisk dialplan for our example VoIP network.
+It supports the following features:
+
+ 1. Internal phones can dial each other via their 4-digit extension.
+
+ 2. Internal phones can dial out to the PSTN via standard 10-digit numbers.
+
+ 3. Internal phones can access the voicemail menu by dialing `*99`.
+
+ 4. Internal phones can show BLF/line status for all other phones.
+
+ 5. Incoming calls from the PSTN will ring all internal phones simultaneously.
+ Callers will be sent to voicemail if no one answers within 25 seconds.
+
+ 6. If your phones support the "auto answer" header, you can initiate a whole-house
+ intercom by dialing `6000`.
+
+I'll provide plenty of comments to help you understand the arcane syntax.
+Hopefully it will be enough to bootstrap your own dialplan!
+
+```lisp
+; /etc/asterisk/extensions.conf
+
+; Remember, context names for each SIP account are specified in pjsip_wizard.conf.
+
+; First, some safeguards against abuse of the built-in contexts.
+[public]
+exten => _X.,1,Hangup(3)
+[default]
+exten => _X.,1,Hangup(3)
+
+
+; Next, some global variables. You'll need to change some of these.
+[globals]
+; Your local area code.
+MY_AREA_CODE = 555
+
+; Your real name and public phone number. This will be used for outgoing calls
+; to the PSTN.
+MY_CALLER_ID = John Doe <+5555555555>
+
+; Dial this number from a local phone to access the voicemail menu.
+VOICEMAIL_NUMBER = *99
+
+; Voicemail address (configured in voicemail.conf)
+VOICEMAIL_BOX = 6000@default
+
+; Ring for this many seconds before forwarding the caller to voicemail.
+VOICEMAIL_RING_TIMEOUT = 25
+
+; Name of the 'ringall' queue for local phones.
+HOME_QUEUE = home-phones
+
+; Number to dial for local intercom.
+INTERCOM = 6000
+
+; Dial pattern for local extensions.
+LOCAL_EXTS = _6XXX
+
+
+; Boilerplate to enable BLF/presence subscriptions. Note that the context name
+; corresponds to the "endpoint/subscribe_context" value in pjsip_wizard.conf.
+[subscribe]
+exten => _XXXX,hint,PJSIP/${EXTEN}
+
+
+; This context handles incoming calls from our SIP trunk provider. Each call is
+; is placed into a ringall queue. If there is no answer, the caller is forwarded
+; to voicemail.
+[from-pstn]
+exten => _X.,1,Queue(${HOME_QUEUE},nr,,,${VOICEMAIL_RING_TIMEOUT})
+ same => n,Answer(500)
+ same => n,Voicemail(${VOICEMAIL_BOX},su)
+ same => n,Hangup()
+
+
+; This is a function (or "gosub" in Asterisk lingo) that sets the "auto answer"
+; SIP header on an outgoing call.
+[gosub-intercom]
+exten => s,1,Set(PJSIP_HEADER(add,Alert-Info)=auto answer)
+ same => n,Return()
+
+
+; This context handles incoming calls from local phones.
+[from-home]
+; When the INTERCOM number is dialed, page all members of the "home-phones" queue
+; into a conference call.
+exten => ${INTERCOM},1,Set(CALLERID(all)=Intercom <${EXTEN}>
+ same => n,Page(${STRREPLACE(QUEUE_MEMBER_LIST(${HOME_QUEUE}),",","&")},db(gosub-intercom^s^1),10)
+ same => n,Hangup()
+
+; For local-to-local calls, ring indefinitely.
+exten => ${LOCAL_EXTS},1,Dial(PJSIP/${EXTEN})
+ same => n,Hangup()
+
+; When the voicemail number is dialed, dump the caller into the voicemail menu.
+exten => ${VOICEMAIL_NUMBER},1,Answer(500)
+ same => n,VoiceMailMain(${VOICEMAIL_BOX},s)
+ same => n,Hangup()
+
+; The following extensions are used to dial out to the PSTN via our SIP trunk,
+; using a personalized caller ID string.
+;
+; Recall that we named our SIP trunk "voipms" in pjsip_wizard.conf.
+;
+; N matches any digit 2-9
+; X matches any digit 1-9
+
+; For numbers formatted as +1xxxxxxxxxx, dial as-is.
+exten => _+1NXXNXXXXXX,1,Set(CALLERID(all)=${MY_CALLER_ID})
+ same => n,Dial(PJSIP/${EXTEN}@voipms)
+ same => n,Hangup()
+
+; For numbers like 1xxxxxxxxxx, add a leading "+".
+exten => _1NXXNXXXXXX,1,Set(CALLERID(all)=${MY_CALLER_ID})
+ same => n,Dial(PJSIP/+${EXTEN}@voipms)
+ same => n,Hangup()
+
+; For numbers without a country code, add a "+1".
+exten => _NXXNXXXXXX,1,Set(CALLERID(all)=${MY_CALLER_ID})
+ same => n,Dial(PJSIP/+1${EXTEN}@voipms)
+ same => n,Hangup()
+
+; For 7-digit numbers, add the local area code.
+exten => _NXXXXXX,1,Set(CALLERID(all)=${MY_CALLER_ID})
+ same => n,Dial(PJSIP/+1${MY_AREA_CODE}${EXTEN}@voipms)
+ same => n,Hangup()
+
+; For 3-digit numbers, like 311, 411, 911 (UNTESTED!!!), dial as-is.
+exten => _N11,1,Set(CALLERID(all)=${MY_CALLER_ID})
+ same => n,Dial(PJSIP/${EXTEN}@voipms)
+ same => n,Hangup()
+```
+
+### Troubleshooting
+
+Once you've got all your configs in place, give Asterisk a kick:
+
+```bash
+systemctl restart asterisk
+```
+
+On RedHat-based distributions, you can check `/var/log/asterisk/messages` for
+errors. You can also check the systemd journal:
+
+```bash
+journalctl -u asterisk
+```
+
+You can also get lots of real-time information from the Asterisk console.
+To try it out, run the following command as root:
+
+```bash
+asterisk -rvvvvv
+```
+
+If you're experiencing connectivity issues with your voice calls, you can
+enable SIP debugging in the console using the following command:
+
+```default
+pjsip set logger on
+```
+
+## Step 4: Configure your IP Phones
+
+The final step is to configure your VoIP phones to connect to your Asterisk server,
+and make a few test calls! The instructions here are for Yealink phones, but they
+should easily translate to other manufacturers.
+
+### SIP Account
+
+Navigate to the IP address of the VoIP phone in your web browser, and log in with the
+default username and password (usually `admin/admin`). From here, you should be able to
+add a SIP account. In the Yealink interface, this is found under _Account→Register_.
+
+For the "Living Room" extension we created above, you would set the following:
+
+- **Register Name:** 6001
+- **Username:** 6001
+- **Password**: s3cret
+- **Server Host:** _IP/hostname of your Asterisk server_
+- **Server Port:** 5060
+- **Transport:** UDP
+
+### Codecs
+
+You should also make sure the appropriate codecs are enabled on the device. In
+the Yealink web interface, you'll find them under _Account→Codec_. I recommend
+enabling these codecs in the following priority:
+
+1. **G722**, for high-quality local calls
+2. **PCMU** (_ulaw_), for dialing out to the PSTN
+
+Asterisk will automatically transcode G722 down to _ulaw_ if the other side doesn't
+support it. You can mess around with even higher quality codecs like Opus, but
+in my experience they are not well supported nor easy to transcode.
+
+### RTP Port Range
+
+To reduce load on the Asterisk server, you may want your VoIP phone to send and
+receive audio streams directly to and from the other party. To do this, you'll
+need to configure your router/firewall to forward all incoming RTP traffic for
+the device.
+
+First, give the device a static IP on your local network. Then, configure your
+router to port-forward all UDP traffic on your chosen RTP port range to that IP.
+
+In the Yealink web interface, you can configure a static RTP port range under
+_Network→Advanced→Local RTP Port_.
+
+If you _don't_ do all this, then make sure `endpoint/direct_media` is set to
+`no` for the SIP account in `pjsip_wizard.conf`!
+
+### Voicemail Number
+
+You'll also want to configure which number the phone should dial when you press
+the voicemail button. In the Yealink web interface, set _Account→Advanced→Voice Mail_
+to `*99` (or whatever you number you chose for `VOICEMAIL_NUMBER` above).
+
+### Busy Lamp Field (BLF)
+
+You may want to have your phone's LED light up when a given line is on a call. In the
+Yealink web interface, you can configure this under _Dsskey→Line Key_.
+
+For each line you're interested in, set **Type** to _BLF_ and **Value** to the other
+party's extension number.
+
+### Intercom
+
+For the intercom functionality described in the dialplan above, make sure the phone
+is configured to allow auto-answer. In the Yealink web interface, you can configure
+this by setting _Features→Intercom→Intercom Allow_ to _on_.
+
+## VoIP on Android: Linphone
+
+In the past, Android provided a built-in SIP client. Sadly, [as of Android 12](https://www.xda-developers.com/android-12-killing-native-sip-calling/),
+this has been removed.
+
+After trying all of the SIP apps available in F-Droid, I'm only able to recommend [Linphone](https://f-droid.org/en/packages/org.linphone/).
+It works with bluetooth devices, has an intuitive interface, and reliably delivers calls.
+
+To keep Android from killing the app, you'll need to make sure battery optimizations
+are disabled. Go to _Settings→Apps→See All Apps→Linphone→App Battery Usage_ and select
+_Unrestricted_.
+
+When configuring the SIP account in Linphone, use the following settings:
+
+- **Username:** 6003 _(or your chosen mobile extension)_
+- **Password:** _your chosen SIP account password_
+- **Domain:** _IP/hostname of your Asterisk server_
+- **Transport:** TCP
+- **Expire:** 3600
+- **Apply prefix for outgoing calls:** _enabled_
+
+You'll definitely want to use the TCP transport for a mobile device. Smartphone
+radios are exceedingly efficient at keeping a long-running TCP connection open,
+and TCP will reliably deliver your SIP packets even with a poor cellular signal.
+
+With _Expire_ set to 3600, your phone will wake up every hour to re-establish the
+SIP connection. I've never had any connectivity issues with the TCP transport, but
+if you're paranoid, you might want to set this to a lower value.
+
+I've also found the following Linphone settings useful:
+
+- **Settings→Audio→Echo cancellation:** _disabled_ (most phones have hardware-level echo cancellation)
+- **Settings→Audio→Adaptive rate control:** _enabled_
+- **Settings→Audio→Codec bitrate limit:** 128 kbits/s
+- **Settings→Audio→Codecs:** _enable PCMU and G722_
+- **Settings→Call→Improve interactions with bluetooth devices:** _enabled_
+- **Settings→Call→Voice mail URI:** \*99
+- **Settings→Advanced→Start at boot time:** _enabled_
+
+You may be tempted to hide the persistent notification icon while Linphone
+is running. Don't do it! Whenever I've tried to hide this icon, I get tons of
+missed calls, and Asterisk reports the device as offline for minutes at a time.
+As long I keep the icon visible in the notification area, I don't have any issues.
+
+
+## Closing Thoughts
+
+For the past few months, I've used this exact setup for my primary phone number,
+and I've been pleased with the results.
+
+If you decide to self-host your own VoIP infrastructure, you'll definitely want
+to configure some type of QoS on your router or firewall to prioritize voice
+traffic. Otherwise, you'll experience lags and poor audio quality whenever
+your network is congested.
+
+In addition, you may want to investigate encrypting your calls via STRP and SIP
+TLS. I don't personally do this, because voice calls are totally in the clear as
+soon as you connect to the PSTN anyway.
+
+If you'd like to check out the configs required for encrypting your calls, or if
+you just want to get some ideas for automating your Asterisk configuration,
+check out my Ansible role on [GitHub](https://github.com/sacredheartsc/selfhosted/tree/master/roles/asterisk).
diff --git a/src/blog/desktop-linux-with-nfs-homedirs/index.md b/src/blog/desktop-linux-with-nfs-homedirs/index.md
new file mode 100644
index 0000000..e01581a
--- /dev/null
+++ b/src/blog/desktop-linux-with-nfs-homedirs/index.md
@@ -0,0 +1,226 @@
+---
+title: Desktop Linux with NFS Home Directories
+date: January 19, 2023
+subtitle: Something no one does anymore, apparently.
+description: Issues you'll face with NFS-mounted homedirs, and some workarounds.
+---
+
+I manage multiple [Rocky Linux](https://rockylinux.org/) workstations that automount
+users' home directories via kerberized NFS. Unfortunately, I don't think this is a common
+setup anymore--I encountered a few bugs and performance issues that needed non-obvious
+workarounds.
+
+## Problems
+
+### 1. Things break when you log in from two places at once
+
+If you can somehow restrict your users to a single GNOME session at any given time,
+you'll probably be fine. However, as soon as someone leaves his desktop running and
+logs into another workstation, strange things begin to happen. Here are some oddities
+I've observed:
+
+ - GNOME settings on one machine are clobbered by the other (this may or may not be desirable).
+
+ - Firefox refuses to run, because the profile directory is already in use.
+
+ - `gnome-keyring` freaks out and creates many login keyrings under `~/.local/share/keyrings`,
+ losing previously stored secrets in the process!
+
+ - Sound quits working (I suspect this is due to `~/.config/pulse/cookie` being clobbered).
+
+ - Flatpak apps completely blow up (each app stores its state in `~/.var`, and
+ [this is nonconfigurable](https://github.com/flatpak/flatpak/issues/1651)). Running
+ multiple instances of `signal-dekstop` instantly corrupts the sqlite database.
+
+ - `goa-daemon` generates thousands of syslog messages per minute (I am unsure if this is
+ due to `~/.config/goa-1.0/accounts.conf` getting clobbered, or a symptom of
+ [this bug](https://gitlab.gnome.org/GNOME/gnome-online-accounts/-/issues/32)).
+ I have no idea what `goa-daemon` does, nor do I want to. I have been victimized by
+ [the bazaar](http://www.catb.org/~esr/writings/cathedral-bazaar/cathedral-bazaar/)
+ enough for one lifetime.
+
+### 2. It's slow
+
+I/O-heavy tasks, like compiling and grepping, will be much slower over NFS than the local
+disk. Browser profiles stored on NFS (`~/.mozilla`, `~/.cache/chromium`, etc.) provide
+a noticeably poor experience.
+
+File browsing is also painful if you have lots of images or videos. Thumbnails for
+files stored on NFS will be cached in `~/.cache/thumbnails`, which is **also** stored
+on NFS!
+
+## Solution: Move stuff to local storage
+
+The [XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html)
+lets you change the default locations of `~/.cache`, `~/.config`, and the like by setting
+some environment variables in the user's session. We can solve most of these problems
+by moving the various XDG directories to the local disk.
+
+### Automatically provision local home directories
+
+First, let's write a script that automatically provisions a _local_ home directory
+whenever someone logs in:
+
+````bash
+#!/bin/bash
+
+# /usr/local/sbin/create-local-homedir.sh
+
+# Log all output to syslog.
+exec 1> >(logger -s -t $(basename "$0")) 2>&1
+
+PAM_UID=$(id -u "${PAM_USER}")
+
+if (( PAM_UID >= 1000 )); then
+ install -o "${PAM_USER}" -g "${PAM_USER}" -m 0700 -d "/usr/local/home/${PAM_USER}"
+fi
+````
+
+Of course, it needs to be executable:
+
+````bash
+chmod 755 /usr/local/sbin/create-local-homedir.sh
+````
+
+Next, we modify the PAM configuration to execute our script whenever anyone logs in
+via GDM or SSH:
+
+````diff
+--- /etc/pam.d/gdm-password
++++ /etc/pam.d/gdm-password
+@@ -1,5 +1,6 @@
+ auth [success=done ignore=ignore default=bad] pam_selinux_permit.so
+ auth substack password-auth
++auth optional pam_exec.so /usr/local/sbin/create-local-homedir.sh
+ auth optional pam_gnome_keyring.so
+ auth include postlogin
+
+--- /etc/pam.d/sshd
++++ /etc/pam.d/sshd
+@@ -15,3 +15,4 @@
+ session optional pam_motd.so
+ session include password-auth
+ session include postlogin
++session optional pam_exec.so /usr/local/sbin/create-local-homedir.sh
+````
+
+<details>
+<summary>A note on SELinux</summary>
+
+If you're using SELinux, you'll need a separate copy of the `create-local-homedir` script
+for use with GDM, labeled with `xdm_unconfined_exec_t`:
+
+````bash
+ln /usr/local/sbin/create-local-homedir{,-gdm}.sh
+semanage fcontext -a -t xdm_unconfined_exec_t /usr/local/sbin/create-local-homedir-gdm.sh
+restorecon -v /usr/local/sbin/create-local-homedir-gdm.sh
+````
+
+Be sure to modify `/etc/pam.d/gdm-password` appropriately.
+
+</details>
+
+### Set XDG Environment Variables
+
+We need to tell the user's applications to use the new local home directory
+for storage. We have to do this early in the PAM stack for GDM, because `$XDG_DATA_HOME`
+must be set before `gnome-keyring` gets executed.
+
+Edit your PAM files again, adding one more line:
+
+````diff
+--- /etc/pam.d/gdm-password
++++ /etc/pam.d/gdm-password
+@@ -1,6 +1,7 @@
+ auth [success=done ignore=ignore default=bad] pam_selinux_permit.so
+ auth substack password-auth
+ auth optional pam_exec.so /usr/local/sbin/create-local-homedir.sh
++auth optional pam_env.so conffile=/etc/security/pam_env_xdg.conf
+ auth optional pam_gnome_keyring.so
+ auth include postlogin
+
+--- /etc/pam.d/sshd
++++ /etc/pam.d/sshd
+@@ -16,3 +16,4 @@
+ session include password-auth
+ session include postlogin
+ session optional pam_exec.so /usr/local/sbin/create-local-homedir.sh
++session optional pam_env.so conffile=/etc/security/pam_env_xdg.conf
+````
+
+Then, create the corresponding `pam_env.conf(5)` file:
+
+````default
+# /etc/security/pam_env_xdg.conf
+
+XDG_DATA_HOME DEFAULT=/usr/local/home/@{PAM_USER}/.local/share
+XDG_STATE_HOME DEFAULT=/usr/local/home/@{PAM_USER}/.local/state
+XDG_CACHE_HOME DEFAULT=/usr/local/home/@{PAM_USER}/.cache
+XDG_CONFIG_HOME DEFAULT=/usr/local/home/@{PAM_USER}/.config
+````
+
+### Hacks for Non-XDG-Compliant Apps
+
+Unfortunately, since a majority of open source developers follow the
+[CADT model](https://www.jwz.org/doc/cadt.html), there are many apps that ignore the
+XDG specification. Sometimes these apps have their own environment variables
+for specifying their storage locations. Otherwise, symlinks can provide us with an escape
+hatch.
+
+Create a script in `/etc/profile.d` for these workarounds. Scripts in this directory
+are executed within the context of the user's session, so we can freely write inside
+his NFS home directory using his UID (and kerberos ticket, if applicable).
+
+````bash
+# /etc/profile.d/local-homedirs.sh
+
+if (( UID >= 1000 )); then
+ # Building code is *much* faster on the local disk. Modify as needed:
+ export PYTHONUSERBASE="/usr/local/home/${USER}/.local" # python
+ export npm_config_cache="/usr/local/home/${USER}/.npm" # nodejs
+ export CARGO_HOME="/usr/local/home/${USER}/.cargo" # rust
+ export GOPATH="/usr/local/home/${USER}/go" # golang
+
+ # Firefox doesn't provide an environment variable for setting the default profile
+ # path, so we'll just symlink it to /usr/local/home.
+ mkdir -p "/usr/local/home/${USER}/.mozilla"
+ ln -sfn "/usr/local/home/${USER}/.mozilla" "${HOME}/.mozilla"
+
+ # Flatpak hardcodes ~/.var, so symlink it to /opt/flatpak.
+ ln -sfn "/opt/flatpak/${USER}" "${HOME}/.var"
+fi
+````
+
+If you use any Flatpak apps, each user will need his own local Flatpak directory.
+The Flatpak runtime appears to shadow the entire `/usr` using mount namespaces,
+so any `/usr/local/home` symlinks will disappear into the abyss. Luckily, `/opt`
+appears to be undefiled. Modify your original script like so:
+
+````diff
+--- /usr/local/sbin/create-local-homedir.sh
++++ /usr/local/sbin/create-local-homedir.sh
+@@ -6,4 +6,5 @@
+
+ if (( PAM_UID >= 1000 )); then
+ install -o "${PAM_USER}" -g "${PAM_USER}" -m 0700 -d "/usr/local/home/${PAM_USER}"
++ install -o "${PAM_USER}" -g "${PAM_USER}" -m 0700 -d "/opt/flatpak/${PAM_USER}"
+ fi
+````
+
+## Closing Thoughts
+
+Most of my users are nontechnical, so I'm pleased that these workarounds do not require
+any manual intervention on their part.
+
+I am sad that `$XDG_CONFIG_HOME` can't be shared between multiple workstations reliably.
+When I change my desktop background or add a new password to `gnome-keyring`, it only
+affects the local machine.
+
+Initially, I tried symlinking various subdirectories of `~/.config` to the local disk
+individually as I encountered different bugs (e.g. `~/.config/pulse`). Unfortunately this
+proved brittle, as I was constantly playing whack-a-mole with apps that abused `$XDG_CONFIG_HOME`
+for storing local state. In the end, it was less of a headache to just dump the whole thing
+onto the local disk.
+
+I suppose if you verified an app behaved properly with multiple simultaneous NFS clients,
+you could always symlink `/usr/local/home/$USER/.config/$APP` **back** onto NFS!
diff --git a/src/blog/index.md b/src/blog/index.md
new file mode 100644
index 0000000..9afe54d
--- /dev/null
+++ b/src/blog/index.md
@@ -0,0 +1,9 @@
+---
+title: "Cullum Smith: Blog"
+heading: Cullum Smith's Blog
+description: I write about self-hosting, privacy, unix, and more.
+---
+
+::: bloglist
+__BLOG_LIST__
+:::
diff --git a/src/blog/makefile-based-blogging/index.md b/src/blog/makefile-based-blogging/index.md
new file mode 100644
index 0000000..9244a0d
--- /dev/null
+++ b/src/blog/makefile-based-blogging/index.md
@@ -0,0 +1,249 @@
+---
+title: Makefile-Based Blogging
+date: December 12, 2022
+subtitle: Yet another static site generator using `pandoc(1)` and `make(1)`.
+description: Building a markdown-based static site generator using pandoc and make.
+---
+
+A few days ago, I got the gumption to start blogging again. The last time I wrote
+with any frequency, I lovingly hand-crafted each HTML file before `rsync`ing it to
+my web server. This time, I wanted a more efficient workflow.
+
+I surveyed the [vast number](https://github.com/myles/awesome-static-generators)
+of static site generators available on GitHub, but most of them seemed like
+overkill for my humble website. I figured that by the time I wrapped by head
+around one of them, I could have just written a Makefile.
+
+Finally, I came across [pandoc-blog](https://github.com/lukasschwab/pandoc-blog),
+which gave me inspiration and showed me the ideal pandoc incantations for
+generating HTML from markdown files. And thus, my
+[Makefile-based static site generator](https://git.sacredheartsc.com/www/about/)
+was born. You're reading the inaugural post!
+
+## Generating the HTML
+
+The workhorse of this thing is [pandoc](https://pandoc.org), which is a ubiquitous
+open-source document converter. Transforming markdown into HTML is as simple as:
+
+```bash
+pandoc document.md -o document.html
+```
+
+Simple! But to generate an entire website, we'll need some of pandoc's additional
+features: custom templates and document metadata.
+
+### Custom Templates
+
+The layout of pandoc's output document is determined by the
+[template](https://pandoc.org/MANUAL.html#templates) in use. Pandoc includes
+default templates for a variety of document formats, but you can also specify
+your own.
+
+A very simple HTML template might look something like this:
+
+```html
+<html lang="en">
+ <head>
+ <meta name="author" content="$author-meta$">
+ <meta name="description" content="$description$">
+ </head>
+ <body>
+ <h1 class="title">$title$</h1>
+$body$
+ </body>
+</html>
+```
+
+[My pandoc template](https://git.sacredheartsc.com/www/tree/templates/default.html)
+is what generates the navigation bar at the top of this page.
+
+The variable `$body$` is replaced by the content of your markdown document when
+pandoc renders the template. The other variables are replaced by their
+corresponding values from the document's metadata.
+
+### Document Metadata
+
+Each pandoc source document can have associated metadata values. There are three
+ways of specifying metadata: the `--medatata` [flag](https://pandoc.org/MANUAL.html#option--metadata),
+a dedicated [metadata file](https://pandoc.org/MANUAL.html#option--metadata-file), or
+a [YAML metadata block](https://pandoc.org/MANUAL.html#extension-yaml_metadata_block)
+embedded within the document itself. We'll be using the embedded metadata blocks.
+
+Each markdown document for my website starts with a YAML metadata block. The
+metadata for the post you're
+[currently reading](https://git.sacredheartsc.com/www/tree/src/blog/makefile-based-blogging/index.md)
+looks like this:
+
+
+```yaml
+---
+title: Makefile-Based Blogging
+date: December 12, 2022
+subtitle: Yet another static site generator using `pandoc(1)` and `make(1)`.
+description: Building a markdown-based static site generator using pandoc and make.
+---
+```
+
+You can put whatever YAML you like in your markdown files, as long as the metadata
+starts and ends with three hyphens.
+
+## Automating pandoc with make
+
+Using a Makefile, we can automatically invoke pandoc to convert each markdown
+file in our blog to HTML. In addition, `make` will keep track of which source
+files have changed since the last run and rebuild them accordingly.
+
+First, lets describe the project layout:
+
+- **src/**: the source files of our blog, including markdown files and static
+ assets (CSS, images, etc). The subdirectory structure is entirely up to you.
+
+- **public/**: the output directory. After running `make`, the contents of this
+ directory can be `rsync`'d straight to your web server.
+
+- **scripts/**: helper scripts for generating the blog artifacts. Currently there
+ are only two:
+
+ - [bloglist.py](https://git.sacredheartsc.com/www/tree/scripts/bloglist.py)
+ generates a markdown-formatted list of all your blog posts, sorted by the
+ `date` field in the YAML metadata block.
+
+ - [rss.py](https://git.sacredheartsc.com/www/tree/scripts/rss.py) generates
+ an RSS feed for your blog.
+
+- **templates/**: pandoc templates which generate HTML from markdown files
+ (currently, there is only one).
+
+The Makefile used to build this website is located [here](https://git.sacredheartsc.com/www/tree/Makefile).
+I've reproduced a simplified version below, to make it easier to step through.
+
+```makefile
+######################
+# Variable definitions
+######################
+
+# These variables are used to generate the RSS feed
+URL = https://www.sacredheartsc.com
+FEED_TITLE = sacredheartsc blog
+FEED_DESCRIPTION = Carolina-grown articles about self-hosting, privacy, unix, and more.
+
+# The number of blog posts to show on the homepage
+BLOG_LIST_LIMIT = 5
+
+# File extensions (other than .md) that should be included in public/ directory
+STATIC_REGEX = .*\.(html|css|jpg|jpeg|png|xml|txt)
+
+# Pandoc template used to generate HTML
+TEMPLATE = templates/default.html
+
+# List of subdirectories to create
+SOURCE_DIRS := $(shell find src -mindepth 1 -type d)
+
+# List of source markdown files
+SOURCE_MARKDOWN := $(shell find src -type f -name '*.md' -and ! -name .bloglist.md)
+
+# List of static assets
+SOURCE_STATIC := $(shell find src \
+ -type f \
+ -regextype posix-extended \
+ -iregex '$(STATIC_REGEX)')
+
+# List of all blog posts (excluding the main blog page)
+BLOG_POSTS := $(shell find src/blog \
+ -type f \
+ -name '*.md' \
+ -and ! -name .bloglist.md \
+ -and ! -path src/blog/index.md)
+
+# Subdirectories to create under public/
+OUTPUT_DIRS := $(patsubst src/%, public/%, $(SOURCE_DIRS))
+
+# .html files under public/, corresponding to each .md file under src/
+OUTPUT_MARKDOWN := $(patsubst src/%, public/%, $(patsubst %.md, %.html, $(SOURCE_MARKDOWN)))
+
+# Static file targets under public/
+OUTPUT_STATIC := $(patsubst src/%, public/%, $(SOURCE_STATIC))
+
+# Script to generate RSS feed
+RSSGEN = scripts/rss.py \
+ src/blog \
+ --title="$(FEED_TITLE)" \
+ --description="$(FEED_DESCRIPTION)" \
+ --url=$(URL) \
+ --blog-path=/blog \
+ --feed-path=/blog/rss/feed.xml
+
+
+######################
+# File Targets
+######################
+
+# Default target: convert .md to .html, copy static assets, and generate RSS
+public: \
+ $(OUTPUT_DIRS) \
+ $(OUTPUT_MARKDOWN) \
+ $(OUTPUT_STATIC) \
+ public/blog/feed.xml
+
+# Homepage (/)
+public/index.html: src/index.md src/.bloglist.md $(TEMPLATE)
+ sed $$'/__BLOG_LIST__/{r src/.bloglist.md\nd}' $< \
+ | pandoc --template=$(TEMPLATE) --output=$@
+
+# Markdown list of 5 most recent blog posts
+src/.bloglist.md: $(BLOG_POSTS) scripts/bloglist.py
+ scripts/bloglist.py src/blog $(BLOG_LIST_LIMIT) > $@
+
+# The main blog listing (/blog/)
+public/blog/index.html: src/blog/index.md src/blog/.bloglist.md $(TEMPLATE)
+ sed $$'/__BLOG_LIST__/{r src/blog/.bloglist.md\nd}' $< \
+ | pandoc --template=$(TEMPLATE) --output=$@
+
+# Markdown list of _all_ blog posts
+src/blog/.bloglist.md: $(BLOG_POSTS) scripts/bloglist.py
+ scripts/bloglist.py src/blog > $@
+
+# Convert all other .md files to .html
+public/%.html: src/%.md $(TEMPLATE)
+ pandoc --template=$(TEMPLATE) --output=$@ $<
+
+# Catch-all: copy static assets in src/ to public/
+public/%: src/%
+ cp --preserve=timestamps $< $@
+
+# RSS feed
+public/blog/feed.xml: $(BLOG_POSTS) scripts/rss.py
+ $(RSSGEN) > $@
+
+
+######################
+# Phony Targets
+######################
+
+.PHONY: serve rsync clean
+
+# Run a local HTTP server in the output directory
+serve: public
+ cd public && python3 -m http.server
+
+# Deploy the site to your webserver
+rsync: public
+ rsync -rlphv --delete public/ webserver.example.com:/var/www/html
+
+clean:
+ rm -rf public
+ rm -f src/.bloglist.md
+ rm -f src/blog/.bloglist.md
+```
+
+## Closing Thoughts
+
+I admit, there is a small amount of hackery involved. You obviously can't generate
+a time-sorted list of blog posts using pure markdown, so I'm generating the
+markdown list using a Python script in an intermediate step. I then (ab)use `sed`
+to shove that list into the markdown source on the fly. This means that changing
+the look of the [blog list](/blog/) requires hacking up the Python code.
+
+But overall, I've been quite happy with this little project. There's just something
+about writing paragraphs in `vi` and typing `:!make` that warms my soul with
+memories of simpler times.
diff --git a/src/blog/reevaluating-rhel/index.md b/src/blog/reevaluating-rhel/index.md
new file mode 100644
index 0000000..f236133
--- /dev/null
+++ b/src/blog/reevaluating-rhel/index.md
@@ -0,0 +1,182 @@
+---
+title: Re-evaluating RHEL
+date: June 25, 2023
+description: Responding to RedHat's latest rug-pull.
+---
+
+Last Wednesday, I woke up to news that IBM's RedHat was [ceasing public releases
+of source code for RedHat Enterprise Linux](https://www.redhat.com/en/blog/furthering-evolution-centos-stream).
+Going forward, "[CentOS Stream](https://www.centos.org/centos-stream/) will now
+be the sole repository for public RHEL-related source code releases."
+
+Prior to last week, all the source RPMs for each RHEL release were published on
+[git.centos.org](https://git.centos.org/). When RedHat abruptly killed CentOS as
+we knew it in 2019 (*requiescat in pace*), the availability of these sources allowed
+fledgeling distros like [Rocky](https://rockylinux.org/) and [Alma](https://almalinux.org/)
+Linux to quickly take its place.
+
+Presumably, the bean-counters at RedHat were none too pleased with the proles'
+clever workaround, and have decided to take their toys and go home. (I'm sure
+[Rocky's recent NASA contract](https://twitter.com/rocky_linux/status/1668781190520918019)
+didn't help either.)
+
+Much pontification has taken place on Twitter and the Fediverse regarding
+the ethical and legal implications of this move. I'm not going to rehash them,
+as I honestly find such things tedious and boring. The Software Freedom Conservancy
+has written a detailed analysis of the situation [here](https://sfconservancy.org/blog/2023/jun/23/rhel-gpl-analysis/).
+
+To summarize: RedHat almost certainly has the right to do this, even if it is
+against the spirit of most free software licenses. At the moment, I'm more
+concerned about how this will affect my own projects.
+
+## I'm heavily invested in Rocky Linux.
+
+I self-host my entire digital footprint on Rocky Linux. Email, XMPP, Matrix, VOIP,
+Mastodon, git repositories, network storage, web servers, desktops...everything
+runs on Rocky virtual machines.
+
+I've spent the last year or so building out an [Ansible framework](https://github.com/sacredheartsc/selfhosted)
+to manage it all. Just as I was getting everything dialed in and perfected,
+RedHat pulled the rug yet again.
+
+Switching to another Linux flavor is not trivial, since my entire infrastructure
+depends on [FreeIPA](https://freeipa.org/) for identity management. User accounts,
+groups, internal DNS records, sudo rules, and access control are all handled by FreeIPA.
+My Ansible framework is tightly coupled to the FreeIPA [Ansible modules](https://github.com/freeipa/ansible-freeipa).
+
+FreeIPA is developed and tested on RedHat-based distributions: Fedora, CentOS,
+and RHEL. While packages do exist for other distros, they're definitely second-class
+citizens. I'd rather not depend on them for production use.
+
+## So what's next?
+
+With nearly all of my digital life dependent on a RHEL derivative, it's time to
+re-evaluate my choice of operating system. Some options:
+
+### Stick with Rocky Linux?
+
+This is definitely the easiest course of action, since it requires no additional
+work on my part. After all, Rocky and Alma have
+[both](https://rockylinux.org/news/brave-new-world-path-forward/)
+[assured](https://almalinux.org/blog/impact-of-rhel-changes/)
+us that updates will keep coming as usual, that this is a minor setback, and that
+everything will be fine. But realistically, what else *could* they say at this point?
+
+It seems like both distros have currently found a way to keep pushing updates, but
+I haven't seen any public statements about how exactly they're accomplishing this (perhaps
+a strategic omission?).
+
+To me, there's three major downsides to sticking with a RHEL-derivative:
+
+- What's stopping RedHat from doing another rug-pull that thwarts whatever
+ future workarounds that Rocky, Alma, *et al.* are using to grab the source RPMs?
+
+- This may be the final straw that causes various FOSS projects to drop RHEL
+ support altogether ([Exhibit A](https://www.jeffgeerling.com/blog/2023/removing-official-support-red-hat-enterprise-linux)).
+
+- By sticking with a RHEL-based distro, I'm giving my implicit support to RedHat's
+ alleged mistreatment of the wider FOSS community. Maybe it's just time to move on?
+
+First, **immediately after** everyone got done migrating the CentOS 8, RedHat
+pulled the plug on CentOS.
+
+Then, **immediately after** the CentOS replacements gained critical mass, RedHat
+pulled the rug on public source code!
+
+*Fool me once, shame on you. Fool me twice, shame on me.*
+
+All that being said, I'd really like to stick with Rocky if possible. It's an
+incredible distro and really hits a sweet spot for professional features (SELinux,
+FreeIPA, RPM packaging), stability, active community, and long support cycles.
+
+### RHEL Developer Program?
+
+Won't work for me. I currently have no fewer than 37 Rocky Linux installs (mostly KVM
+virtual machines), but RedHat's [free tier](https://developers.redhat.com/articles/faqs-no-cost-red-hat-enterprise-linux)
+only gives you a license for 16 hosts.
+
+### CentOS Stream?
+
+My understanding of [CentOS Stream](https://www.centos.org/centos-stream/) is that it's
+essentially a beta branch for the next point-release of RHEL. I'd like a distro where I
+run automatic updates and not concern myself with stuff breaking. It doesn't *sound*
+like this is the case for Stream. Am I wrong?
+
+RedHat [asserts](https://blog.centos.org/2020/12/centos-stream-is-continuous-delivery/) that
+"To the untrained eye, CentOS Stream is already as stable as RHEL." If that is really the case,
+why did so many people jump to Rocky/Alma? Spite? (This is not sarcasm--I'm genuinely curious.)
+
+CentOS Stream gets security updates through the RHEL "full support" phase (5.5 years). If Stream
+is truly "as good as RHEL, but only for 5 years," then I'd consider this a viable option.
+
+### Ubuntu LTS?
+
+Hard pass. `/dev/null` will soon be provided by a Snap package at the rate things are going.
+
+### Switch to Debian?
+
+If Rocky disappears, Debian is probably the most logical choice. It's been around
+forever with no corporate ties, and has near-universal package availability. In addition,
+I already run a Debian-based hypervisor ([Proxmox](https://www.proxmox.com/)).
+
+There are some downsides though:
+
+- Debian Stable is only supported for 5 years, compared to RHEL's 10 years (this
+ doesn't actually bother me *that* much).
+
+- `apt` is annoying compared to `dnf`...but this is a minor complaint.
+
+- Janky FreeIPA support. I'm sure you can `apt-get install freeipa-server` and
+ have things *mostly* work, but roughly no one runs a FreeIPA domain on Debian. I'd
+ definitely be off the beaten path.
+
+Maybe I'm exaggerating the issues with Debian-based FreeIPA, but I haven't had good
+experiences with it in the past. I've also run Samba 4 in [domain controller mode](https://wiki.samba.org/index.php/Setting_up_Samba_as_an_Active_Directory_Domain_Controller)...don't
+think I can go through that again.
+
+Another option would be to roll a poor-man's FreeIPA with
+[OpenLDAP](https://www.openldap.org/),
+[BIND](https://www.isc.org/bind/),
+a [Kerberos KDC](https://web.mit.edu/kerberos/),
+and
+[nslcd](https://github.com/arthurdejong/nss-pam-ldapd). This seems like a lot
+of work, but maybe it would pay off in the long run to be totally decoupled from RHEL?
+
+### FreeBSD? Illumos?!
+
+A move to [FreeBSD](https://www.freebsd.org/) or an Illumos-based distro like
+[OmniOS](https://omnios.org/) does have a certain Unix nostalgia appeal.
+
+FreeBSD has [jails](https://docs.freebsd.org/en/books/handbook/jails/), and each release
+is supported for 5 years. OmniOS has Solaris [zones](https://docs.oracle.com/cd/E19455-01/817-1592/zones.intro-1/index.html),
+which are amazing, but the LTS release only has a 3-year support window.
+
+I would honestly prefer to use a real Unix, since Linux has run on the [CADT model](https://www.jwz.org/doc/cadt.html)
+since the 2000s. Unfortunately, since we live in a Linux monoculture, using anything
+not-Linux means you must also become a package maintainer, and spend your days
+filing issue reports for your bespoke hipster Unix in various bug trackers.
+
+I actually used to run my entire infrastructure on [SmartOS](https://www.tritondatacenter.com/smartos),
+but it feels like betting on a losing horse at this point. Debian will almost certainly be
+around 10 years from now. *Illumos*...?
+
+## I'm mostly just annoyed.
+
+The classic CentOS model was stable, reliable, and boring: the perfect platform
+for my self-hosted fiefdom. I have a regular `$DAYJOB` and a growing family--three
+small kids and counting! I need a low-maintenance distro that
+stays out of my way for long periods of time. So far, Rocky Linux has provided
+exactly that.
+
+In the short term, I'll keep my eyes on the RHEL situation and continue maintaining
+[sacredheart-selfhosted](https://github.com/sacredheartsc/selfhosted) as a
+Rocky Linux-based framework.
+
+I don't really care about bug-for-bug compatibility with RHEL. If Rocky, Alma, or
+Stream manages to emerge as some kind of community-favorite "almost-RHEL" with a
+longish support cycle, that's what I'll stick with. Otherwise, I see Debian in my future.
+
+<aside>
+*P.S. I wrote this article on the feast of the Nativity of Saint John the Baptist, patron of
+the Diocese of Charleston and of unborn children. Saint John the Baptist, pray for us!*
+</aside>
diff --git a/src/browserconfig.xml b/src/browserconfig.xml
new file mode 100644
index 0000000..b3930d0
--- /dev/null
+++ b/src/browserconfig.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<browserconfig>
+ <msapplication>
+ <tile>
+ <square150x150logo src="/mstile-150x150.png"/>
+ <TileColor>#da532c</TileColor>
+ </tile>
+ </msapplication>
+</browserconfig>
diff --git a/src/css/style.css b/src/css/style.css
new file mode 100644
index 0000000..38fb832
--- /dev/null
+++ b/src/css/style.css
@@ -0,0 +1,105 @@
+html {
+ -moz-text-size-adjust: none;
+ -webkit-text-size-adjust: none;
+ text-size-adjust: none;
+}
+
+body {
+ color: #333;
+ margin: 1em auto 2em auto;
+ max-width: 43em;
+ padding: 0 1em;
+ font-family: "PT Sans", "Myriad Pro", "Trebuchet MS", Helvetica, sans-serif;
+}
+
+a {
+ color: #0074D9
+}
+
+a:visited {
+ color: #941352
+}
+
+blockquote {
+ border-left: 3px solid #ccc;
+ padding-left: 10px;
+}
+
+dt {
+ font-weight: bold;
+}
+
+header .subtitle {
+ margin-top: -1em;
+ margin-bottom: 21.44px;
+}
+
+.bloglist p:nth-of-type(2) {
+ margin-top: -8px;
+}
+
+header .date {
+ font-style: italic;
+ margin-bottom: 21.44px;
+}
+
+footer .date {
+ float: right;
+}
+
+footer {
+ font-style: italic;
+ font-size: 14px;
+ margin-top: 32px;
+}
+
+.logo {
+ float: right;
+ border-radius: 50%;
+}
+
+@media only screen and (max-device-width : 667px) {
+ .logo {
+ max-width: 66px;
+ }
+
+ footer .date {
+ float: none;
+ }
+}
+
+@media print {
+ .navbar {
+ display: none !important;
+ }
+}
+
+pre {
+ background-color: #f5f5f5;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ font-family: monospace;
+ line-height: 1.2;
+ padding: 8px;
+ white-space: pre;
+ word-wrap: normal;
+}
+
+.sourceCode {
+ overflow: auto;
+}
+
+details {
+ font-size: 87.5%;
+ margin: 0 1em;
+}
+
+.redacted {
+ background: black;
+ color: black;
+ cursor: default;
+}
+
+.right {
+ float: right;
+}
diff --git a/src/cv/index.md b/src/cv/index.md
new file mode 100644
index 0000000..5db53da
--- /dev/null
+++ b/src/cv/index.md
@@ -0,0 +1,106 @@
+---
+title: "Cullum Smith: CV"
+heading: Cullum Smith
+subtitle: Curriculum Vitae
+description: "CV of Cullum Smith: Site Reliability Engineer in South Carolina"
+date: July 1, 2024
+---
+
+[cullum@sacredheartsc.com](mailto:cullum@sacredheartsc.com){.right}
+SRE • Trading Systems • Linux/Unix
+
+Site reliability engineer specializing in the programming, administration, and
+troubleshooting of production Linux systems within trading platforms. 9+ years
+of development and operations experience. Strong skills in Unix internals, shell
+scripting, system administration, and debugging, from userspace to the kernel.
+Experienced in C, Python, Perl, and Ocaml, as well as networking, storage, and
+infrastructure management of both bare-metal servers and virtual machines.
+Extensive knowledge of Linux, FreeBSD, OpenBSD, and Solaris-based operating systems.
+Highly motivated engineer that can solve low-level systems issues in fast-paced environments.
+
+
+## Work Experience
+
+**Linux Engineer** at [REDACTED, Inc.]{.redacted} (Remote)
+[2021-present]{.right}
+
+ - Details forthcoming.
+
+**Linux Engineer** at **[Jane Street](https://www.janestreet.com/)** (New York, NY)
+[2018-2021]{.right}
+
+ - Investigated production errors and performance regressions using packet captures,
+ userspace profiling, and kernel instrumentation.
+ - Maintained Linux infrastructure and developed management tools for network
+ storage systems.
+ - Diagnosed performance bottlenecks in NFS application workflows and client/server
+ implementations.
+ - Designed and implemented automated systems for the deployment, configuration
+ management, and monitoring of Linux hosts.
+ - Wrote patches for bug fixes and performance improvements for various open source
+ applications in the GNU/Linux ecosystem.
+
+**Site Reliability Engineer** at **Thesys CAT** (Charleston, SC)
+[2017-2018]{.right}
+
+ - Designed infrastructure, networking, and operations strategies for the
+ implementation of the SEC’s Consolidated Audit Trail.
+ - Led infrastructure rollout and configuration management effort using Ansible
+ for hundreds of bare metal and cloud servers.
+ - Implemented Kerberos-based authentication system for both applications and user
+ accounts.
+ - Built an object storage solution using FreeBSD and ZFS nearing a petabyte of
+ usable space.
+ - Created automated build and packaging pipeline for in-house software repositories.
+
+**DevOps Engineer** at **Thesys Technologies** (Charleston, SC)
+[2016-2017]{.right}
+
+ - Implemented market access software in C for Tilera’s TILE64 architecture, supporting
+ over $1 billion per day of order volume.
+ - Improved the accuracy of distributed C-based client risk checks while maintaining
+ low order latency.
+ - Responsible for implementation and monitoring of a C++-based software trading
+ platform as part of trading technologies DevOps team.
+ - Coordinated network connectivity and colocation with clients and stock exchanges
+ in North America.
+ - Assessed and resolved real-time trading and connectivity issues in a high-stress
+ environment while managing relationships with customers.
+
+**Software Engineer** at **SPARC** (Charleston, SC)
+[2015-2016]{.right}
+
+ - Developed benefit management software for the Department of Veterans Affairs
+ using Java, Spring Framework, and AngularJS.
+ - Led a special performance team which identified and mitigated application
+ bottlenecks.
+ - Achieved a >10x speedup by refactoring application-layer ORM logic into more
+ efficient database queries.
+ - Created and maintained CentOS VM images for Oracle Database.
+
+**Graduate Research Assistant** at **[Clemson University](https://www.clemson.edu/)** (Clemson, SC)
+[2013-2015]{.right}
+
+ - Designed and implemented a middleware system for watershed-scale sensor networks
+ using Java and NodeJS.
+ - Maintained a middleware backend which served over 50 live ecological sensor
+ deployment sites across South Carolina.
+ - Independently developed a metadata management system for sensor network hardware,
+ which was presented at IEEE and ACM conferences.
+
+
+## Education
+
+**M.S. in Computer Science** at **[Clemson University](https://www.clemson.edu/)** (Clemson, SC)
+[2013-2015]{.right}
+
+ - Developed new data analytics and management tools for wireless sensor networks
+ in Clemson’s Dependable Systems Research Group.
+ - GPA 3.70
+
+**B.S. in Computer Science** at **[Clemson University](https://www.clemson.edu/)** (Clemson, SC)
+[2009-2013]{.right}
+
+ - Graduated magna cum laude as an undergraduate researcher in the Calhoun
+ Honors College.
+ - GPA 3.82
diff --git a/src/favicon-16x16.png b/src/favicon-16x16.png
new file mode 100644
index 0000000..e64368b
--- /dev/null
+++ b/src/favicon-16x16.png
Binary files differ
diff --git a/src/favicon-32x32.png b/src/favicon-32x32.png
new file mode 100644
index 0000000..caa141a
--- /dev/null
+++ b/src/favicon-32x32.png
Binary files differ
diff --git a/src/favicon.ico b/src/favicon.ico
new file mode 100644
index 0000000..4536c08
--- /dev/null
+++ b/src/favicon.ico
Binary files differ
diff --git a/src/gpg.asc b/src/gpg.asc
new file mode 100644
index 0000000..2f9bdb3
--- /dev/null
+++ b/src/gpg.asc
@@ -0,0 +1,52 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+mQINBGaCEccBEADDnez2wGlgPft31DNNPR6Cin7546QkMxvWmVNOpOcH5mZbECCj
+Yr0no60dh7RZsRjuttVhySESmPRZ0FHbU8oDU9evNX5sjoJbUXW/MN1Fz2OU4Lcq
+wAujIbvHCpzX7j6tZmzlikDI8XvagVxGZdEvzIpn6bJlC+Zzt4p/Kwk+IuyCMzjv
+HfDp81JI8E24PXg8WQnOO6kEma6kEEQR4X+eUn5JSwoklSECntj0YChJ0I3N4kCo
+I1aYjSZzd7Nb8cYF9HPOz+U66mgGNbYav/EyNxn7/FBGV6AyJhskyJt9xi04FnIn
+x6MPjsbtvIJbyKBAehGfL1gCD7JEXJhip/89yPRfhRpr4XcdbD56ijUdt0apRuG9
+031pwDb2cl34ppTm5K2ZgsTCBoJr1hV1A4c8u1eOr0rwe6xsZwwELfQCVp1paeTd
+jnd0VgcHr3ET3Xip8flWNl1pXCHnhCxRI63/RQjTDq3lPPNrIFIZK/aul8X9lftS
+XrnwFagR3H39EvspD0OS7MHxfTBay8f/bLP6IeHRQZ40jioaueknWwR7qCseLEJ4
++9QtSoCj3e5aD7bAVnIXgNuoZJCauplnKimGyJogy84YnrZ6O+ZNQ50Ha2bJV7QE
+xAM3vNBQtHAKyzkm5P4w/phHIJJq69cXxoxCdFZ3FuqUbJ45cdzSft6WRQARAQAB
+tCdDdWxsdW0gU21pdGggPGN1bGx1bUBzYWNyZWRoZWFydHNjLmNvbT6JAlIEEwEI
+ADwWIQS05WSTddG9RNBtyVlqUpDoXK9ikQUCZoIRxwIbAwULCQgHAgMiAgEGFQoJ
+CAsCBBYCAwECHgcCF4AACgkQalKQ6FyvYpH/EA//aLh+kfK3OBBYC7k3QfSNuMzk
+V+ApCVOrKwljX6Bj2RhfziAbaXjwzhJw8or2KQGpHqXhkfzLeMVkE+YNMAIzDh4O
+UY9Bpu+x8rX+eHyrSgvR8U3x8ebkUeK/hx2DeD55JVYc12Sd+kd9EHuh89t9wSYV
+h+i0jM9DQm3hXMhjpl4xEeNWUVols/Ah8AM326qU2eYR4wzCV3KOSUUCTCdT1jCH
+8Ne2KeAe9wIJIyFnyVD21+FA5uYUahE8xJmYED7OZF6bUyuHVlwsvOPa5Z6Eay0f
+/Mrsm4DtVGWZzNRrI3B2YGLq/YDiMZP1PRWe1zObTKDlqp7EW1AULPglPblEPFM4
+rofylKcTaf3168719n5fUmTUAIfd9Pxvxh9HBSsUNMvu2v/fgc2WJ5BgAGLbFx8A
+hsjAy/3cYpa40tuQsV0iikVgtdKah1k73yx5Kn5hyuxRbh+q8e5IvoC2iC5hGy2e
+i7pNV11k3J0wUVPl114AzXFpnRgTFJO1X6zdrR9E4cnveF6eMDZ8U2YDQQTRbUWz
+Q/bxb5DFf8+qYaRyRdkyip/3xCIntahPH/VEXwYH3d6BJWT9x3F35dEMCr2/qSNG
+qIfE9hwVyWCIX5hXJR3quuCiExJApuHRbcfkRk5jw38VZ4lJ+NNa76Goe8qPcHHP
+erT4fqcgOrtZDq7S6Ta5Ag0EZoIRxwEQALRSlkjO3KjJ+obot5EV1aoZfpQx0yOz
+ioA9e089+D/OxFZJjoZR+Xo4mRYebgD0VwXXTDu83trCVCrFfdaCNKG2E9x5hEB7
++hKY6gEvE9L3xaWRSbJfy1iemJLF7NqSEKQtXDw8SownTx6zhmA/vpmUSmrAnUCr
+2iPoF4okoppPhSYqwS0bzHyvsR3VESPsUlH8QiehFYVkQ5nDs+Etz3q7qfklrORO
+RCfPzTDK50FYA9Cz3G/NVjtAJydJ1DznuUxuSkAgD8kA+ktYe/GBD7LWTeDqIcqk
+gtXXRmkdFwGap5Tuhlku73mGt6+IZ8nUVDwIvS9rc+V3TIsuDaquanGslwbdQSCD
+JSelF8O4Mz4kCutWf5ULgTldtsALyNb6CuVkNuwytfDp2Y1fsQ503jHLlyvqBqZT
+CabAsSCcfHs9lsMJ8/wIMdP6SPD+D4s7L8bAzSMhuQdwlGowJhL3Z2Ki+p447GrL
+9tEgOmTcgJr7U5xnupPmmAO2yNEHrSJJoLBhVrhtq20Cg0JcpCApltuohlFCpdx+
+IbnHsw6Jh+ywE6cRwWIMAt4q1u/JR1JKjzjUN/4OboqIgIyDXijwt54p/YB1neLk
+cJkqTIFmnv5NEm+cEN5WfYXbxtYmrzl+i6qjCOUM+mrRtB7NTWjLOnlp2q2tB2bp
+VJB2ihIZ3xmfABEBAAGJAjYEGAEIACAWIQS05WSTddG9RNBtyVlqUpDoXK9ikQUC
+ZoIRxwIbDAAKCRBqUpDoXK9ikaKTD/45a8YJO5LDyKCP2HWWZSEJpJLycJ/kQ5JZ
+MMduRd8LJmECqJ1jyqxMcGlPf6p/O9t4AjkhwEsprns1+fGqtGUfDrYSpLDzW7p6
+jt1ocF7Zh4EnUPzNpDjJDAaDsLKrFOXNPxhnCHIIqLgKUV4o5rR7opPkJETkvjuE
+FHEHN28I4H4173KK1gytlD9jG0qDIwZTmnGqAxi9zmciyOsedJbUDSMUTxIYZnuD
+so/8f3oVCoNPzumKKA8VJLBpmzEez9C3lIQRB+fCwJpXhigSK0C5ppwFujIxRyRi
+9CUFtjT2qfh91JUOw6UDdP1C4Fy4vizDFsGwvF7qoG8agXUuDt+w8FeR9Aw0hii7
+r7aQc+jxv8kH5XTtZzglIy8x8ta5Q9qAGmOZoJ/c5aYq13/BjZOeO/i514NhcsXD
+gZutLlCpkEEE7Lp3lsIOKofws7/uGW8oQQ0m+GKeaZCiKbinvvnr2DLT0ztPZcjU
+1duBS7q+tBoK+CWcPf8UKAbBX+Wz5pUGKY+BmiEDbo2xYFk50ZPTpROw6OpjMnRC
+vTR9TEiYOaPvz/WpJ44KeQDIcuk1hMAnu4Dm5/pw6evCSqoGtHTa5c37v3OSuXrW
+ce6bsb2YmMwNMfXS04xLU/jzGE9VStkFxZErfd4A9SueI0eihqoj0azolFukOjFl
+xiYWNdqf/g==
+=9a7V
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/src/index.md b/src/index.md
new file mode 100644
index 0000000..9e9864e
--- /dev/null
+++ b/src/index.md
@@ -0,0 +1,40 @@
+---
+title: "Cullum Smith: Homepage"
+heading: Cullum Smith's Homepage
+subtitle: Cor Jesu Sacratissimum, miserere nobis
+description: "Personal website of Cullum Smith: dad, southerner, unix wrangler, banjo enjoyer"
+---
+
+I'm an Site Reliability Engineer located in South Carolina. Welcome to my personal website.
+
+## About Me
+![](me.jpg "Cullum Smith"){.logo}
+
+- Catholic
+- Father of four young'uns
+- Unix wrangler
+- Gregorian chant & banjo enjoyer
+- Southerner
+- SRE (*i.e.* sysadmin who knows how to code)
+
+## Contact
+- Email: [cullum@sacredheartsc.com](mailto:cullum@sacredheartsc.com)
+- XMPP: [cullum@sacredheartsc.com](xmpp:cullum@sacredheartsc.com?message)
+- IRC: [cullum on libera.chat](ircs://irc.libera.chat/cullum,isnick)
+- Big Tech: [X](https://x.com/CullumSmith) |
+ [GitHub](https://github.com/cullumsmith) |
+ [LinkedIn](https://www.linkedin.com/in/cullumsmith/) |
+ [Facebook](https://www.facebook.com/CullumSmith01) |
+ [Instagram](https://www.instagram.com/cullumsmith01/)
+- GPG: [0x5CAF6291](/gpg.asc)
+
+## Links
+- [Blog](/blog/)
+- [Code](https://git.sacredheartsc.com/)
+- [Curriculum Vitae](/cv/)
+
+## Recent Posts
+
+::: bloglist
+__BLOG_LIST__
+:::
diff --git a/src/me.jpg b/src/me.jpg
new file mode 100644
index 0000000..c35e242
--- /dev/null
+++ b/src/me.jpg
Binary files differ
diff --git a/src/mstile-150x150.png b/src/mstile-150x150.png
new file mode 100644
index 0000000..8d29538
--- /dev/null
+++ b/src/mstile-150x150.png
Binary files differ
diff --git a/src/robots.txt b/src/robots.txt
new file mode 100644
index 0000000..c2a49f4
--- /dev/null
+++ b/src/robots.txt
@@ -0,0 +1,2 @@
+User-agent: *
+Allow: /
diff --git a/src/safari-pinned-tab.svg b/src/safari-pinned-tab.svg
new file mode 100644
index 0000000..0580306
--- /dev/null
+++ b/src/safari-pinned-tab.svg
@@ -0,0 +1,89 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="202.000000pt" height="202.000000pt" viewBox="0 0 202.000000 202.000000"
+ preserveAspectRatio="xMidYMid meet">
+<metadata>
+Created by potrace 1.14, written by Peter Selinger 2001-2017
+</metadata>
+<g transform="translate(0.000000,202.000000) scale(0.100000,-0.100000)"
+fill="#000000" stroke="none">
+<path d="M376 1935 c-4 -20 -10 -33 -14 -31 -5 3 -14 1 -22 -5 -13 -7 -12 -9
+3 -9 9 0 17 -6 17 -12 0 -9 4 -8 10 2 5 8 10 11 10 6 0 -5 11 1 25 14 29 28
+30 33 9 54 -23 24 -31 20 -38 -19z"/>
+<path d="M454 1895 c-7 -18 2 -45 15 -45 13 0 22 27 15 45 -4 8 -10 15 -15 15
+-5 0 -12 -7 -15 -15z"/>
+<path d="M526 1884 c-9 -8 -16 -19 -16 -24 0 -5 -3 -16 -6 -24 -5 -12 -2 -15
+10 -10 9 3 23 1 31 -6 12 -10 15 -10 15 0 0 7 6 10 14 7 8 -3 21 2 30 12 18
+20 20 31 6 31 -5 0 -10 -6 -10 -13 0 -7 -13 -13 -30 -13 -27 -2 -29 0 -23 27
+6 33 1 36 -21 13z"/>
+<path d="M1025 1860 c-3 -5 -1 -10 4 -10 6 0 11 5 11 10 0 6 -2 10 -4 10 -3 0
+-8 -4 -11 -10z"/>
+<path d="M1536 1855 c4 -8 11 -15 16 -15 6 0 5 6 -2 15 -7 8 -14 15 -16 15 -2
+0 -1 -7 2 -15z"/>
+<path d="M1010 1830 c0 -5 5 -10 10 -10 6 0 10 5 10 10 0 6 -4 10 -10 10 -5 0
+-10 -4 -10 -10z"/>
+<path d="M480 1785 c-10 -12 -10 -15 4 -15 9 0 16 7 16 15 0 8 -2 15 -4 15 -2
+0 -9 -7 -16 -15z"/>
+<path d="M637 1783 c-4 -3 -7 -18 -6 -32 1 -26 1 -25 10 3 9 31 7 41 -4 29z"/>
+<path d="M300 1771 c0 -5 5 -13 10 -16 6 -3 10 -2 10 4 0 5 -4 13 -10 16 -5 3
+-10 2 -10 -4z"/>
+<path d="M1016 1770 c-42 -8 -58 -27 -63 -75 -4 -37 -9 -77 -12 -90 -1 -5 -6
+-3 -11 5 -5 8 -10 10 -10 4 0 -6 -10 -9 -23 -7 -13 3 -40 -2 -60 -11 -30 -12
+-37 -21 -38 -43 -1 -15 0 -32 1 -38 2 -5 4 -18 5 -28 3 -29 21 -39 82 -44 32
+-3 60 -7 62 -9 8 -8 13 -48 14 -129 2 -91 -8 -129 -32 -119 -61 26 -115 15
+-179 -37 l-49 -40 -13 -90 c-12 -82 -11 -93 5 -132 10 -23 27 -51 37 -63 10
+-11 18 -28 18 -37 0 -10 11 -27 25 -39 14 -12 25 -27 25 -33 0 -7 20 -31 45
+-54 25 -23 45 -45 45 -50 0 -4 44 -54 99 -111 l99 -104 -28 -26 c-16 -15 -34
+-45 -41 -69 -11 -38 -15 -42 -43 -41 -53 3 -59 1 -53 -20 7 -26 -1 -25 -24 2
+-19 21 -19 22 1 43 41 44 16 73 -31 36 -62 -48 -92 -30 -44 27 14 17 25 33 25
+36 0 10 -29 7 -49 -6 -15 -9 -25 -9 -43 -1 l-23 12 27 0 c42 1 58 11 58 36 0
+27 -20 37 -61 28 -28 -5 -30 -4 -23 21 5 21 3 26 -13 26 -27 0 -93 -58 -93
+-81 0 -11 4 -18 9 -14 10 5 51 -32 51 -46 0 -3 14 -12 31 -19 22 -9 29 -18 26
+-31 -6 -21 31 -49 62 -49 11 0 22 -9 26 -19 3 -10 20 -24 38 -31 18 -6 45 -18
+60 -27 15 -8 47 -16 70 -17 23 -2 47 -4 52 -6 25 -6 56 1 52 13 -2 6 -10 12
+-18 12 -33 1 -41 16 -35 68 10 69 22 90 45 77 23 -12 31 -4 31 28 0 17 4 23
+15 18 9 -3 37 11 68 34 28 22 55 40 60 40 4 0 7 6 7 13 0 8 9 22 20 32 11 10
+18 20 16 23 -3 2 12 32 33 65 39 65 81 145 98 187 29 72 49 195 33 205 -6 3
+-7 17 -4 30 9 38 -24 116 -68 157 -31 30 -50 38 -96 45 -65 9 -101 6 -119 -9
+-25 -20 -37 -2 -39 63 -7 177 -7 179 30 179 34 1 104 15 113 24 10 10 -8 96
+-24 112 -12 11 -27 14 -52 10 -20 -3 -46 -3 -59 1 -22 5 -23 10 -22 77 2 96 3
+91 -25 104 -28 13 -41 13 -99 2z"/>
+<path d="M520 1761 c0 -5 7 -11 15 -15 8 -3 15 -1 15 3 0 5 -7 11 -15 15 -8 3
+-15 1 -15 -3z"/>
+<path d="M460 1740 c0 -5 5 -10 11 -10 5 0 7 5 4 10 -3 6 -8 10 -11 10 -2 0
+-4 -4 -4 -10z"/>
+<path d="M440 1704 c0 -8 5 -12 10 -9 6 4 8 11 5 16 -9 14 -15 11 -15 -7z"/>
+<path d="M688 1693 c7 -3 16 -2 19 1 4 3 -2 6 -13 5 -11 0 -14 -3 -6 -6z"/>
+<path d="M635 1420 c-3 -5 -1 -10 4 -10 6 0 11 5 11 10 0 6 -2 10 -4 10 -3 0
+-8 -4 -11 -10z"/>
+<path d="M1592 743 c3 -26 1 -28 -29 -25 -27 3 -33 0 -33 -15 0 -25 61 -48 88
+-34 40 21 33 101 -9 101 -16 0 -19 -6 -17 -27z"/>
+<path d="M1460 750 c0 -5 9 -10 20 -10 11 0 20 5 20 10 0 6 -9 10 -20 10 -11
+0 -20 -4 -20 -10z"/>
+<path d="M592 688 c-17 -17 -15 -31 8 -51 11 -9 20 -22 20 -29 0 -10 -3 -9 -9
+1 -7 11 -13 8 -26 -13 l-18 -27 -26 31 c-24 29 -28 31 -53 20 -37 -17 -43 -48
+-12 -72 13 -11 21 -24 18 -30 -4 -6 1 -8 15 -3 18 5 21 2 21 -20 0 -19 4 -26
+14 -22 8 3 20 0 26 -8 6 -7 17 -12 25 -11 24 3 95 83 95 106 0 11 7 23 16 27
+8 3 13 12 10 19 -3 8 -1 14 4 14 6 0 10 7 10 15 0 9 -9 15 -25 15 -24 0 -51
+31 -38 44 3 3 -9 6 -28 6 -19 0 -40 -5 -47 -12z m29 -147 c-7 -5 -11 -13 -7
+-19 3 -5 -1 -12 -9 -16 -9 -3 -19 3 -25 14 -8 15 -7 18 3 14 8 -3 20 2 27 11
+7 8 15 13 19 10 3 -3 -1 -10 -8 -14z"/>
+<path d="M1493 641 c-13 -11 -23 -25 -23 -31 0 -19 40 -70 55 -70 16 0 25 -22
+25 -61 0 -27 -3 -29 -38 -29 -31 0 -45 7 -66 31 -25 27 -32 30 -64 24 -87 -16
+-120 -109 -51 -146 34 -17 47 4 26 40 -12 21 -14 32 -6 36 13 9 104 -83 111
+-112 9 -35 -14 -68 -47 -68 -32 0 -43 -16 -21 -34 18 -15 59 -5 81 19 23 25
+25 113 2 137 -24 27 -12 34 43 27 46 -6 47 -5 61 27 7 19 13 52 12 74 0 24 5
+44 14 50 32 24 -31 106 -80 105 -7 -1 -22 -9 -34 -19z"/>
+<path d="M777 543 c-4 -3 -7 -15 -7 -25 0 -13 7 -18 25 -18 26 0 29 7 19 34
+-6 16 -26 21 -37 9z"/>
+<path d="M1133 283 c-7 -2 -13 -11 -13 -19 0 -8 -5 -14 -10 -14 -6 0 -9 -12
+-6 -27 2 -16 5 -31 5 -35 1 -4 18 -19 38 -33 43 -29 63 -20 38 18 -16 24 -16
+28 1 56 15 25 15 32 5 45 -13 16 -34 19 -58 9z"/>
+<path d="M430 160 c-9 -6 -10 -10 -3 -10 6 0 15 5 18 10 8 12 4 12 -15 0z"/>
+<path d="M360 116 c0 -19 4 -23 20 -19 23 6 25 14 8 31 -18 18 -28 14 -28 -12z"/>
+<path d="M418 128 c-7 -19 2 -38 18 -38 15 0 17 9 8 34 -7 19 -20 21 -26 4z"/>
+<path d="M286 94 c-3 -9 -2 -23 3 -32 10 -18 11 -16 27 36 5 18 -23 15 -30 -4z"/>
+</g>
+</svg>
diff --git a/src/site.webmanifest b/src/site.webmanifest
new file mode 100644
index 0000000..c9761d6
--- /dev/null
+++ b/src/site.webmanifest
@@ -0,0 +1,14 @@
+{
+ "name": "SacredHeartSC",
+ "short_name": "SacredHeartSC",
+ "icons": [
+ {
+ "src": "/android-chrome-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ }
+ ],
+ "theme_color": "#ff0000",
+ "background_color": "#ff0000",
+ "display": "standalone"
+}
diff --git a/templates/cv.html b/templates/cv.html
new file mode 100644
index 0000000..71ed4c7
--- /dev/null
+++ b/templates/cv.html
@@ -0,0 +1,88 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+$for(author-meta)$
+ <meta name="author" content="$author-meta$">
+$endfor$
+$if(date-meta)$
+ <meta name="dcterms.date" content="$date-meta$">
+$endif$
+$if(description)$
+ <meta name="description" content="$description$">
+$endif$
+$if(keywords)$
+ <meta name="keywords" content="$for(keywords)$$keywords$$sep$, $endfor$">
+$endif$
+ <title>$if(title-prefix)$$title-prefix$ – $endif$$title$</title>
+ <link rel="alternate" type="application/rss+xml" href="$feed$">
+ <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
+ <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
+ <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
+ <link rel="manifest" href="/site.webmanifest">
+ <link rel="mask-icon" href="/safari-pinned-tab.svg" color="#eea35f">
+ <meta name="msapplication-TileColor" content="#da532c">
+ <style>
+ code{white-space: pre-wrap;}
+ span.smallcaps{font-variant: small-caps;}
+ span.underline{text-decoration: underline;}
+ div.column{display: inline-block; vertical-align: top; width: 50%;}
+$if(quotes)$
+ q { quotes: "“" "”" "‘" "’"; }
+$endif$
+ </style>
+$if(highlighting-css)$
+ <style>
+$highlighting-css$
+ </style>
+$endif$
+$for(css)$
+ <link rel="stylesheet" href="$css$">
+$endfor$
+$for(header-includes)$
+ $header-includes$
+$endfor$
+</head>
+<body>
+$for(include-before)$
+$include-before$
+$endfor$
+
+<nav class="navbar">
+ [<a href="/">home</a>]&nbsp;
+ [<a href="/blog/">blog</a>]&nbsp;
+ [<a href="https://git.sacredheartsc.com/">git</a>]&nbsp;
+ [<a href="mailto:cullum@sacredheartsc.com" title="cullum@sacredheartsc.com">email</a>]&nbsp;
+ [<a href="$feed$">rss</a>]
+ <span style="float: right">JMJ</span>
+<hr>
+</nav>
+
+<header>
+$if(heading)$
+ <h1 class="title">$heading$</h1>
+$else$
+ <h1 class="title">$title$</h1>
+$endif$
+$if(subtitle)$
+ <p class="subtitle">$subtitle$</p>
+$endif$
+</header>
+
+$if(toc)$
+<nav id="$idprefix$toc" role="doc-toc">
+$table-of-contents$
+</nav>
+$endif$
+
+$body$
+
+$for(include-after)$
+$include-after$
+$endfor$
+<footer>
+ <p><span class="date">Last updated $date$</span></p>
+</footer>
+</body>
+</html>
diff --git a/templates/default.html b/templates/default.html
new file mode 100644
index 0000000..db11d67
--- /dev/null
+++ b/templates/default.html
@@ -0,0 +1,88 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+$for(author-meta)$
+ <meta name="author" content="$author-meta$">
+$endfor$
+$if(date-meta)$
+ <meta name="dcterms.date" content="$date-meta$">
+$endif$
+$if(description)$
+ <meta name="description" content="$description$">
+$endif$
+$if(keywords)$
+ <meta name="keywords" content="$for(keywords)$$keywords$$sep$, $endfor$">
+$endif$
+ <title>$if(title-prefix)$$title-prefix$ – $endif$$title$</title>
+ <link rel="alternate" type="application/rss+xml" href="$feed$">
+ <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
+ <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
+ <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
+ <link rel="manifest" href="/site.webmanifest">
+ <link rel="mask-icon" href="/safari-pinned-tab.svg" color="#eea35f">
+ <meta name="msapplication-TileColor" content="#da532c">
+ <style>
+ code{white-space: pre-wrap;}
+ span.smallcaps{font-variant: small-caps;}
+ span.underline{text-decoration: underline;}
+ div.column{display: inline-block; vertical-align: top; width: 50%;}
+$if(quotes)$
+ q { quotes: "“" "”" "‘" "’"; }
+$endif$
+ </style>
+$if(highlighting-css)$
+ <style>
+$highlighting-css$
+ </style>
+$endif$
+$for(css)$
+ <link rel="stylesheet" href="$css$">
+$endfor$
+$for(header-includes)$
+ $header-includes$
+$endfor$
+</head>
+<body>
+$for(include-before)$
+$include-before$
+$endfor$
+
+<nav class="navbar">
+ [<a href="/">home</a>]&nbsp;
+ [<a href="/blog/">blog</a>]&nbsp;
+ [<a href="https://git.sacredheartsc.com/">git</a>]&nbsp;
+ [<a href="mailto:cullum@sacredheartsc.com" title="cullum@sacredheartsc.com">email</a>]&nbsp;
+ [<a href="$feed$">rss</a>]
+ <span style="float: right">JMJ</span>
+<hr>
+</nav>
+
+<header>
+$if(heading)$
+ <h1 class="title">$heading$</h1>
+$else$
+ <h1 class="title">$title$</h1>
+$endif$
+$if(subtitle)$
+ <p class="subtitle">$subtitle$</p>
+$endif$
+$if(date)$
+<p class="date">$date$ AD</p>
+$endif$
+</header>
+
+$if(toc)$
+<nav id="$idprefix$toc" role="doc-toc">
+$table-of-contents$
+</nav>
+$endif$
+
+$body$
+
+$for(include-after)$
+$include-after$
+$endfor$
+</body>
+</html>