aboutsummaryrefslogtreecommitdiff
path: root/src/blog
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 /src/blog
downloadwebsite-56a863f9f11340a9310907e4131b9dc7483df623.tar.gz
initial commit
Diffstat (limited to 'src/blog')
-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
5 files changed, 1657 insertions, 0 deletions
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>