diff options
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 @@ -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 Binary files differnew file mode 100644 index 0000000..f731faa --- /dev/null +++ b/src/android-chrome-192x192.png diff --git a/src/apple-touch-icon.png b/src/apple-touch-icon.png Binary files differnew file mode 100644 index 0000000..0491547 --- /dev/null +++ b/src/apple-touch-icon.png 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 Binary files differnew file mode 100644 index 0000000..e64368b --- /dev/null +++ b/src/favicon-16x16.png diff --git a/src/favicon-32x32.png b/src/favicon-32x32.png Binary files differnew file mode 100644 index 0000000..caa141a --- /dev/null +++ b/src/favicon-32x32.png diff --git a/src/favicon.ico b/src/favicon.ico Binary files differnew file mode 100644 index 0000000..4536c08 --- /dev/null +++ b/src/favicon.ico 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 Binary files differnew file mode 100644 index 0000000..c35e242 --- /dev/null +++ b/src/me.jpg diff --git a/src/mstile-150x150.png b/src/mstile-150x150.png Binary files differnew file mode 100644 index 0000000..8d29538 --- /dev/null +++ b/src/mstile-150x150.png 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>] + [<a href="/blog/">blog</a>] + [<a href="https://git.sacredheartsc.com/">git</a>] + [<a href="mailto:cullum@sacredheartsc.com" title="cullum@sacredheartsc.com">email</a>] + [<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>] + [<a href="/blog/">blog</a>] + [<a href="https://git.sacredheartsc.com/">git</a>] + [<a href="mailto:cullum@sacredheartsc.com" title="cullum@sacredheartsc.com">email</a>] + [<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> |