More from Rubenerd
I feel like I righted a wrong in some small way recently. It also gives me an excuse to write another of my shaggy dog posts about old computers, which I’ll never pass up the opportunity to do. Behold, our boxed copy of Digital Research DOS 6.0 from 1992: The first OS I ever used was MS-DOS 5.x on the family 486, upgraded shortly after to MS-DOS 6.2 [sic], then Windows 95. DOS was “what came with computers” to me at the time, so the idea there were alternatives didn’t even cross my mind. MS-DOS’s origins lay with QDOS, itself a broad clone of Digital Research’s CP/M operating system. CP/M-86 was compiled and offered for the original PC, but IBM famously went with Microsoft instead. The circumstances surrounding this decision remain… contentious. Either way, I’ve had a soft spot for Gary Kildall since I first saw him on those archived episodes of the Computer Chronicles. People like exploring an alternative universe where IBM went with CP/M over DOS, and Gary had become the billionaire tyrant instead of Gates. We can only speculate, but Gary always struck me as more of a self aware, Woz-type character than Bill. I’ve mentioned how Digital Research’s GEM desktop was a big part of the reason why I got into the Atari ST, but I haven’t talked much about DR DOS, the OS Digital Research released to compete with Microsoft and IBM. I never used it as a kid, but I wondered what Gary and his team had been able to deliver. Last year I acquired disk images for every major release, in part for a blog post series about DOS that I never ended up publishing. Version 6.0 quickly supplanted IBM PC DOS 7 as my preferred DOS; everything from the installer to the included utilities are vastly superior, and it even came with ViewMAX, a file manager based on GEM/3. Awesomesause! Pair this with Turbo Pascal, Commander Keen, Sim City 2000, The Games: Winter Challenge, the Brief editor, and the DOS port of Lemmings, and I’ve got all the bases covered! DR DOS 6 was released in 1992, around the time Digital Research was acquired by Novell. For context, Lynne and William Jolitz released 386BSD at the same time, and Microsoft was selling MS-DOS 5.0 to my dad for our first family PC. He could have ended up with DR DOS 6.0 instead, which makes this all the more interesting. This boxed set of DR DOS 6 includes the instruction manuals for DOS and ViewMAX, along with a handy reference card. The “Value Pack” sleeve contained the install disks, but I’m not sure if it previously had something else. The OS itself is contained on two 5.25-inch high density disks, along with the original voucher for 360 KiB disk versions. So many of the disk labels from the time seem to have developed this blotchy appearance, but fortunately it’s only cosmetic. Funnily enough, my PC DOS 2.1 disk is about the only one from the early 1980s that hasn’t developed this odd label issue. In the next post, I’ll get a basic install of DR DOS 6 going, to demonstrate why I like it so much. Only took me (checks calendar) two and half decades. By Ruben Schade in Sydney, 2026-09-02.
Snow Leopard was a version of Mac OS X released in 2010. It was billed by Apple as having “0 new features”, with a focus on refining the system, improving efficiency, and fixing bugs. Those of us using Macs at the time welcomed the news after living with updates that seemingly broke as many things as they introduced with each release. This idea is so powerful—and so longed for—that it’s escaped containment among the Apple crowd. I’ve seen everything from Linux distro to phone updates referred to as Snow Leopard releases, when their vendors cite stability and bug fixes over new features. Likewise, people plead with their vendors for a Snow Leopard release when they feel quality has slipped. The reality at the time was a bit different. As I wrote at the time: This could be the first time I’ve ever downgraded an Apple OS. As we speak I’m downgrading the Snow Leopard partition on my trusty old Core Duo 2006 MacBook Pro back to Leopard (and while I’m at it, I’m finally getting around to upgrading the FreeBSD partition to 8.0!). I kept waiting for updates to fix the severe stability problems in the Finder, my FireWire 800 ExpressCard and my iMovie HD plugins, but I’ve decided to give up for now. This was putting it mildly. When I tried it again a few months later, I still had so many problems that I downgraded again, then leapfrogged it for Lion when it was released in 2011. Jeff Johnson has a great post discussing all the problems Snow Leopard introduced and had to have patched. It was a mess. Still, whether Snow Leopard really was this bastion of stability and polish, the myth surrounding its development and stated purpose obviously resonates today. People invoke its memory because they’re broadly craving stability and quality of life improvements, something almost everyone has wilfully discarded as they chase the next rung of enshittification. Apple’s marketing team hit on something so popular that it has staying power sixteen years later. By Ruben Schade in Sydney, 2026-08-02.
I worked briefly in process control when I was younger, and saw Things.™ One of those Things™ was a wall of LEDs to indicate statuses of various valves and sensors across the entire plant. It was old school, but it worked. It also made you feel like you were on a 1960s-era sci-fi set. What does this have to do with databases? That’s an extremely excellent, and dare I say handsome, question. While we’re on the topic of things I used to do, I used to work against a large database that had a schema with a specific quirk that used to drive me up the wall. Take this contrived example of a table for an account: username | active | paid ---------------+--------+------ nagato_yuki | true | true takarada_rikka | true | false And another for some basic infrastructure: hostname | running | error ------------------+---------+------- retro.rubenerd.au | true | false www.sasara.moe | true | true Do you spot the subtle difference between the two? Why is the first table well designed, and the second one horrible? The first table has uniform positive attributes. Having an active account is good, and being paid is good. This means one can tell at a glance whether an account is in good standing or not. You can also do trivial boolean algebra to derive a status. The second table has a mix of positive and negative attributes. Having a running state of true is good, but having an error state of true is bad. This means you can’t as easily tell at a glance the status of an instance. You can’t also as easily derive an overall status. The value of consistent booleans was something that was immediately obvious when looking at that status wall. It mostly had white lights. When one of these changed colour, you knew there was something actionable. If that status wall instead was designed with some lights being positive, and some being negative, it would be almost (but perhaps not entirely) useless. ☕︎ ☕︎ ☕︎ Why am I thinking about this today? I’ll admit it’s for an incredibly embarrassing reason: I had a dream about databases last night. I know, some people have fun dreams involving flight, reliving something they enjoyed, or exploring a new side of themselves. I dreamed I was back at uni with my DB professor who insisted that all booleans should have positive assertions. Anyway, I was shy at the time when I went to that class in the real world, and said nothing. But what I wanted to say was that uniformity is more important. Most of the time there’s an unambiguous, convenient term to describe something positive, but I also acknowledge there are some circumstances where forcing this naming can get clumsy, such as having a schema full of not_$foo attributes. It’s not a data science to me, but I’m reminded of the sixth rule from The Economist Style Guide: Break any of these rules sooner than say anything outright barbarous. The same applies for software variables, but that would be a whole other post, can of worms, and kettle of fish. By Ruben Schade in Sydney, 2026-07-01.
I was going through my drafts folder, and came across a post I’d written way back in 2018. I’m not sure what site I was browsing at the time, but it was still a fun observation. If I came across this error in a more modern context, I’d assume site owners are placing limits to deal with the onslaught of gen-“AI” DDoS attacks. ☕︎ ☕︎ ☕︎ I was browsing an online store, like a gentleman, when I saw a message that sent me back to the Web 1.0 days: Due to an above average number of visitors, the maximum number of connections have been reached. Please try again later. I wonder how many other sites still have a codified ceiling on the number of visitors? It reminds me of the old days of CGI. This used to be much more common in my experience, but thesedays sites are expected to scale indefinitely. Or in the case of one of our banks, let their servers grind to a near halt when too many account holders try to log in at once. If I were more cynical, I’d claim they do it because they have plausible deniability among a client base who may not be tech savvy. “Not sure what you’re talking about, it loads fine for us! Have you tried turning your modem off and on again?” Calculating these limits when you’re talking about database transactions, processes, threads, or memory is somewhat easier, because you’re dealing with finite, measurable metrics. But my favourite are those (almost always rude) client calls that begin by asking how many clients can access a remote desktop, without any context about what applications the clients will run, or what their connections are. Sure thing, and how long is a piece of string, good sir? By Ruben Schade in Sydney, 2026-04-05.
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.