More from Good Enough
Back in March we added Album Search in Album Whale. This was a very nice update because it meant you no longer had to first grab an album share link from a music service (e.g. Spotify, Apple Music, Bandcamp, etc) in order to save an album to a list. You can just head straight to Album Whale and do it all there. Today we’ve added another feature to Album Whale with a similar goal in mind, this time on the other end of the spectrum: now you can listen to an album right in Album Whale! No more needing to jump over to a music service first to do so. Hover over any album artwork and you’ll find a green ▶︎ play button, which opens a dialog containing music service embeds for that album, like this: While amazing for discovering new music quickly, I’ve also found it very useful for my own To Try list. Now this entire flow can happen within Album Whale after hearing of a new album I want to listen to at some point: Add it to my To Try list in Album Whale using search Listen to it right in Album Whale If I like it, copy it to another list in Album Whale (oh ya, this is another small feature we added!) A couple more notes: For albums added to Album Whale using a Bandcamp share link, those green play buttons just link back to Bandcamp. At least for Spotify, I found I needed to be logged into Spotify on the web to have their embeds in Album Whale play the whole album, not just a preview. This might also be true for other music services. We hope you discover many great new tunes this summer! Listen on Album Whale Reply by email
As we say repeatedly on its homepage, Pika is designed to help you focus on writing, not tinkering with your blog design. To that end Pika purposely doesn’t support complex liquid templating or other such code customization. However, today I thought I’d share how crazy powerful custom CSS can be in some circumstances where you want to augment Pika. Pika has a “Scroll to top” button floating in the bottom right of the Dashboard. Some people have asked if they can have this on their own blog. This may or may not be a feature we build right into Pika one day, but in the meantime, you can get close to a full implementation yourself in Pika. Here’s how: First, head to Pika’s Settings, scroll down to Site footer and add this to the bottom of the site footer editor: ↑ — This is an up arrow that is linking to #top, which will now be on every page of your Pika site. After saving that, head to Settings > Theme, scroll down to Additional options and check “Add custom CSS”. In the resulting code editor that appears, let’s add some CSS that targets that specific link, and designs it as a floating button: .user-site-footer a[href="#top"] { /* Float this in the bottom right of the page */ position: fixed; bottom: var(--space-S); right: var(--space-S); z-index: 1; /* Centered styling */ display: grid; place-content: center; /* Button styling with built-in Pika style variables */ background-color: var(--color-primary); border-radius: var(--radius-round); color: var(--color-txt-on-primary); font-family: var(--font-family); font-size: var(--font-L); height: var(--space-XL); width: var(--space-XL); text-decoration: none; } Depending on if you have other custom CSS, you might need to add !important to any of the style lines above. That’s it! Now you have a floating back to top button on your Pika blog, designed to look like other buttons on your site. But if you want to go the extra mile, here’s how you would make it so the back to top button only shows up after scrolling a bit (since it’s not so useful when you’re already at the top of the page): .user-site-footer a[href="#top"] { /* Float this in the bottom right of the page */ position: fixed; bottom: var(--space-S); right: var(--space-S); z-index: 1; /* Centered styling */ display: grid; place-content: center; /* Button styling with built-in Pika style variables */ background-color: var(--color-primary); border-radius: var(--radius-round); color: var(--color-txt-on-primary); font-family: var(--font-family); font-size: var(--font-L); height: var(--space-XL); width: var(--space-XL); text-decoration: none; /* Hide it until a little bit of page scroll */ transition: 200ms; opacity: 0; pointer-events: none; } .scrolled-a-bit .user-site-footer a[href="#top"] { opacity: 1; pointer-events: all; } Feel free to make this button your own, like adding a box-shadow (since it’s floating), or making it a rounded square, or whatever you’d like. If you’re really clever, you could probably get the button to say “Scroll to top” when you hover on it, though that would take quite a bit more HTML and CSS — but it’s possible! I leave that as an exercise for you. Reply by email
Did you know that we, Good Enough, make a little website called Album Whale where you can make beautiful lists of albums to share with your friends, the world, or just yourself? It’s true! We haven’t made an update there in quite some time (it’s pretty good enough as-is), but I was inspired this week. Adding an album to a list previously required you to first grab an album share link from a music service of your choice (e.g. Spotify, Apple Music, Bandcamp, etc). This has always been a bit cumbersome. I’m excited to share you can now just search for an album right away in Album Whale! Like this: 🔍 💿 🎉 No need to first go somewhere else, you can jump right to Album Whale when you want to save an album to a list. I’ve found this really made my private “To Try” list more useful. Behind-the-scenes we’re using MusicFetch, which supports most of the big music services. We aren’t sure, but there may be some albums that aren’t found via search? If so you can still paste in a link as you always could. Reply by email
Continuous integration is a great thing, and having tests and security checks run before every deploy is also a great thing. But if you’re a developer who has been shipping production code for more than a week, you definitely understand how much it can all feel like a house of cards that tumbles down nearly every day. The Good Enough suite of products have been using GitHub Actions to make sure our automated test suites run before each deployment. The (mostly free) servers GitHub offers are predictably slow, with the Pika test suite generally taking close to ten minutes to run. (To that you say, “Delete most of your system tests!” Alas, due to Pika’s lovely editor, we unfortunately have to maintain quite a few system tests for the service.) Even when upgrading, and paying for, a higher-strength GitHub Action server we were seeing runs approaching eight minutes for Pika. That’s already no fun, but even worse is the fact that our system tests were a bit flaky in the GitHub Actions environment. We eventually got the hint that running system tests in parallel just isn’t possible, but even running them one test at a time would lead to odd failures in part because of how slow things move in the Actions environment. So imagine the cycle of trying to deploy a Pika update and needing to run continuous integration two, three, or four times. Frustration! There’s got to be a better way! There is. Hopefully. With the arrival of Rails 8.1 came the option to set up local CI. As a team of two wanting to move a little more quickly and with a little less frustration, this seems like a perfect fit. Here’s how I’ve set it up for Pika… ci.rb: # Run using bin/ci CI.run do step "Setup", "bin/setup --skip-server" step "Security: Gem audit", "bin/bundler-audit" step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error --confidence-level 2" step "Security: Importmap vulnerability audit", "bin/importmap audit" step "Tests: Rails", "bin/rails test" step "Tests: System", "bin/rails test:system" step "Tests: Seeds", "env RAILS_ENV=test bin/rails db:seed:replant" # Set a green GitHub commit status to unblock PR merge. # Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`. if success? step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff" else failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again." end end In order for the importmap vulnerability audit to run successfully, I needed to update our gemfile with openssl: group :development, :test do gem "openssl" end Here’s an excerpt of Pika’s application_system_test_case.rb: ENV["PARALLEL_WORKERS"] ||= "1" # System tests seem less flakey when not run in parallel require "test_helper" class ApplicationSystemTestCase < ActionDispatch::SystemTestCase browser_options = Selenium::WebDriver::Chrome::Options.new.tap do |opts| opts.add_argument("--window-size=1200,800") opts.add_argument("--disable-extensions") # Disable non-foreground tabs from getting a lower process priority opts.add_argument("--disable-renderer-backgrounding") # Normally, Chrome will treat a 'foreground' tab instead as backgrounded if the surrounding # window is occluded (aka visually covered) by another window. This flag disables that. opts.add_argument("--disable-backgrounding-occluded-windows") # Suppress all permission prompts by automatically denying them. opts.add_argument("--deny-permission-prompts") opts.add_argument("--enable-automation") end Capybara.register_driver :chrome_headless do |app| browser_options.add_argument("--headless") Capybara::Selenium::Driver.new(app, browser: :chrome, options: browser_options) end Capybara.register_driver :chrome do |app| Capybara::Selenium::Driver.new(app, browser: :chrome, options: browser_options) end if ENV["SYSTEM_TESTS_BROWSER"] driven_by :chrome, screen_size: [ 1200, 1000 ] else driven_by :chrome_headless, screen_size: [ 1200, 1000 ] end end Prerequisites to run local CI: brew install gh gh auth login gh extension install basecamp/gh-signoff Run: gh signoff install This installs the GitHub command-line interface, installs the signoff extension for GitHub command-line, and turns on the signoff requirement in your repo. Here’s the process: Get all your changes pushed to a branch and make a PR Make sure your local environment doesn’t have any lingering file changes or CI will fail Run bin/ci Upon successful completion of local CI, signoff will land on your branch, and you can merge and push to main. If you ever need to move quickly, say in an emergency situation: > gh signoff create -f > git push Since Lettini and I are both super-duper admins in our GitHub account, we needed one more update to protect us from willy-nilly pushing to main. I had to update a setting on GitHub in each repository. I clicked on Do not allow bypassing the above settings in repo > branches > Branch protection rules > main > edit: It’s not all rainbows and unicorns In an ideal world, hands-off CI is a really great thing. It will take a bit for these steps to become muscle memory. I hope they do! System tests are still notoriously flaky, but running tests only in our local environments means we shouldn’t have to account for both general flakiness and super-slow-test-running flakiness. GitHub has a useful feature called Dependabot, which can apply security updates to your dependencies and create a pull request that’s often ready to merge. Sometimes we’ve just clicked that merge button in the past, feeling confident because our test suite had already run in GitHub Actions. Now we’ll have to pull down those branches to go through a local CI and signoff step in order to merge things. If local CI doesn’t end up fitting us, I’ve also discovered there are faster, GitHub-Action-based alternatives for automating CI, such as Blacksmith. These services also have historically been cheaper than increasing server power at GitHub, though recent policy changes at GitHub have changed that math. And a thank you I’d be remiss if I didn’t thank 37signals for opening up their Fizzy repository. This helped me to really streamline our application_system_test_case.rb, which had become a Frankenstein’s monster of a thing as I troubleshot system test issues over the years.
More in technology
Well, well, well, well, well, well, well, well, well, well, well, well, well, well, well. We're back. Sorry. We've been watching the onslaught of vulnerabilities flood the internet. Every man, dog, and their grandmas (apparently?) are now using LLMs to find and reproduce vulnerabilities - it’
You want less of them. That’s the reason. You may find that it’s too hard to stop people from doing the thing, literally blood, sweat, and tears trying to prosecute people, but that’s a different thing.
Solitaire Alone Together I made a new game. It's called Solitaire Alone Together. It's Windows 98 solitaire, but you can play with everyone else on the internet. Read the full post on my blog! Here's a raw link, if you need it: https://eieio.games/blog/solitaire-alone-together
This post is a living diary of all the times I messed up something with my website in a funny way. I value those who have the confidence to own their mistakes and share the learning with others, and so this is me doing just that! That Time I Accidentally Made a Tarpit That Time I Accidentally Made Really Large Headers That Time I Accidentally Made a Tarpit Back to Top A "tarpit" is an unofficial term used in computing to describe an intentionally slow response to a request. In these modern times many people are using tarpits as a way to combat the relentless theft of data by AI companies, although there's little to no evidence of that actually being in any way effective. I don't use tarpits, at least not intentionally, but there was that one time when I accidentally created a tarpit and trapped all visitors in it. As I've shared previously, I refuse connections from IP addresses that are blocked or belong to a blocked subnet, and I enforce this firewall during the TCP handshake. The logic here is straightforward: there's no reason to waste resources doing a TLS handshake, accepting an HTTP request, and then rejecting the connection if I already know I'm going to reject it at the earliest step. At the time, the code worked like this: the HTTP server would repeatedly call the Accept() function below expecting a new connection. I've added some comments to help explain the logic. func (l *firewallListener) Accept() (net.Conn, error) { // Accept the connection from the TCP listener. This blocks until there is a connection to accept or the listner was closed. conn, err := l.l.AcceptTCP() if err != nil { return conn, err } // Separate the IP address out from the remote address (which includes the port) ip := utils.SocketStringToIPAddress(conn.RemoteAddr().String()) if ip == nil { return nil, nil } // Check if it's blocked, if so close the connection and return a refuseError if IsBlocked(ip, true) { conn.Close() return nil, &refuseError{} } // Otherwise return the connection on to the HTTP server return conn, nil } If the incoming connection was from a blocked IP then I'd return a refuseError. I need to use a specific error interface because the HTTP server will halt if it encounters a non-temporary error from the call to Accept(), so I need to return an error that satisfies the definition of a temporary error. I defined refuseError like this: type refuseError struct{} func (e *refuseError) Error() string { return "." } func (e *refuseError) Timeout() bool { return true } func (e *refuseError) Temporary() bool { return true } func (e *refuseError) Is(err error) bool { return err == context.DeadlineExceeded } This did accomplish the goal of rejecting connections before the TLS handshake for blocked addresses, but it had one really unintended and difficult to track down side-effect. Accepting connections is done serially, after which servers typically then process that request on a dedicated thread (or in Go's case a goroutine). This means that any delays during the accept loop will block all incoming connection. What I had missed while reviewing the code for Go's HTTP server is that when it receives a temporary error from Accept() is that while it doesn't abort, it does sleep for up to a maximum of 1 second. This sleep blocks the entire server for all incoming connections. You can see a trimmed copy of the code that does this below, with some marks I've added which I will explain. // src/net/http/server.go // Copyright 2009 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. for { // (1) rw, err := l.Accept() if err != nil { if s.shuttingDown() { return ErrServerClosed } // (2) if ne, ok := err.(net.Error); ok && ne.Temporary() { if tempDelay == 0 { tempDelay = 5 * time.Millisecond } else { tempDelay *= 2 } if max := 1 * time.Second; tempDelay > max { tempDelay = max } s.logf("http: Accept error: %v; retrying in %v", err, tempDelay) // (3) time.Sleep(tempDelay) continue } return err } connCtx := ctx if cc := s.ConnContext; cc != nil { connCtx = cc(connCtx, rw) if connCtx == nil { panic("ConnContext returned nil") } } tempDelay = 0 c := s.newConn(rw) c.setState(c.rwc, StateNew, runHooks) // before Serve can return // (4) go c.serve(connCtx) } At mark 1 the server calls the Accept() function, this is the exact function that I defined above where I might return a temporary error. At mark 2 it checks if an error was returned, and if so if that error is temporary. If there was a temporary error, at mark 3 it sleeps for an increasing amount of time up-to 1 second, otherwise, at mark 4 it processes the connection on a dedicated goroutine, which allows the server to accept the next connection. I'm not entirely sure why the Go developers added this sleep delay and the change when it was introduced doesn't provide any meaningful insight. Regardless, it caused significant latency connecting to my website when a flood of rejected requests was coming in. It just goes to show how important it is to write meaningful commit messages, because you never know when somebody might come back years later wondering "why was this done?". I sure home I don't come to eat those words later. Coincidentally, you can actually see this happening if you look carefully at one of the metric graphs I shared in my first post about my server's security model: Securing My Web Infrastructure. This is the graph I shared in that blog post and while I didn't know it at the time, the fact that these request spikes all cap-out at around 60 requests per minute was not a coincidence. These requests were not being made with a limit in mind, attackers rarely ever care about things like that, instead it the accidental tarpit I had created. The downside to this was that while the malicious requests were being rate-limited, all requests were being rate-limited, up to a point of taking so long they timed out. The Fix Fixing the issue was relatively straightforward enough. Instead of returning a temporary error to the HTTP server during the accept loop, just don't return anything at all and wait for the next valid connection. func (l *firewallListener) Accept() (net.Conn, error) { for { conn, err := l.l.AcceptTCP() if err != nil { return conn, err } ip := utils.SocketStringToIPAddress(conn.RemoteAddr().String()) if ip == nil { return nil, nil } if IsBlocked(ip, true) { conn.SetLinger(0) conn.Close() continue } return conn, nil } } Now, when the HTTP server calls Accept(), the only time it returns is with a connection from an IP that isn't blocked, or if there genuinely is an error. No more sleep delays, no more excessive timeouts. That Time I Accidentally Made Really Large Headers Back to Top For about 10 years now all major browsers have support for a security feature known as a Content Security Policy or CSP. A CSP is an HTTP header provided by the server that instructs the browser on where it can load assets from, this could be scripts, images, stylesheets, fonts, etc. The objective of using a CSP is to prevent against injected HTML that tries to load assets, such as a malicious Javascript file, from a remote source. With so much user-provided content being available online, it's very possible for this to happen without an attacker compromising the entire web server. CSP protects against that by saying "scripts can only be loaded from these domains". That's a really simplified way of looking at it, anyways. My web server supports injecting the CSP header automatically, but before I go on I need to explain a little bit about the structure of my web server. When an incoming HTTP request is accepted (having passed all firewall checks and assertions), we look at the destination host for the request. This can either be the value of the Host header or as specified during the TLS handshake. We then look at a map of hosts to apps. Apps are just an interface that accept a few methods: type App interface { Cleanup() ReloadConfig() ServeHTTP(rw http.ResponseWriter, r *http.Request) Setup(dataDir string) error Shutdown() } One of the apps is the Proxy app, which is a reverse proxy - it accepts the incoming HTTP request and then proxies it on to another host. This is a very common design, especially with increasingly complex TLS setups. Because each app is unique to a host, and different hosts have different requirements for CSP rules, the proxy app includes a CSP preset that we use to build the header value, or skip it entirely. When the proxy app was going to copy an HTTP request to the downstream host, it would build the CSP header, however there was a slight bug... func (a *App) ServeHTTP(rw http.ResponseWriter, inRequest *ht2.Request) { // --snip -- if a.CSP != nil { a.CSP.ConnectSrc += " " + inRequest.Origin } CopyHttpRequest(inRequest, outRequest, rw, CopyHttpRequestOptions{ Origin: inRequest.Origin, Csp: a.CSP, Cors: a.CORS, AddHeaders: !a.SkipHeaders, UseHTTP3: a.UseHTTP3, InsecureTLS: a.InsecureTLS, }) } I'm really unsure as to what I was doing with the line to append to the ConnectSrc, but the impact is that I'm appending to a variable that lives on the App, rather than a variable that is per-request. This meant that every time there was a request to the app, any request at all, the origin would be appended to the header value. This went on for quite a long time unnoticed and unresolved, largely because I am constantly tweaking and tinkering with my web server, after all, it's how I made having a website fun again. Each time I restarted the server process, the header value would be reset, but only for it to continue to grow and grow. Eventually, after a period of being busy with other matters, the server process stayed running for long enough that the header value grew too large and HTTP clients began to reject it. There is no defined maximum for an HTTP header value, however most HTTP clients use 100KiB, which is perfectly reasonable, and this header value would continue to grow well beyond that. Diagnosing this issue turned out to be difficult as tools like Curl would fail with errors relating to entities being too large, but stopped short of saying what specifically. I eventually used openssl s_client to send an HTTP request by hand and observed my terminal window being filled with a domain name repeated thousands of times. Looking at the commit history, it was really unclear why I added the culprit lines of code. The commit message just says "Improved CSP support". It just goes to show how important it is to write - hey look it's those words I'm now having to eat! The Fix The fix was to just delete those three lines of code. Yup, it really was that simple, and fixing this bug actually made a larger positive impact than I had expected, as it was immediately clear when I fixed the bug by looking at outbound network bytes: So much traffic was being wasted on excessive header sizes. You might look at these mistakes I've made and think "wow, Ian, these are some obvious mistakes, I never would have made them!" to which I say "good for you!" with the utmost sarcasm and disdain. I enjoy making and refining software, and making anything means making mistakes along the way. Each time I make mistakes such as the ones above, I improve my skills of investigation, diagnosing, and repair. Skills that, judging by my peers in the industry, seemingly everyone is quickly willing to throw away because a robot does it "better" than you. Header Image: "Car accident on the Ffestiniog to Bala road. Nobody was hurt" by Geoff Charles, CC BY-SA 4.0, via Wikimedia Commons.