The amount of posts and videos that I’ve seen in the past ~12 months which are announcing the end of Free Open Source Software is off the charts. From AI tools being set loose on several project’s issue and pull requests boards, to a veritable flood of security vulnerabilities being discovered by automated tools, not forgetting the burn out that many developers are feeling in these agentic times, and the fall of GitHub; it would seem that FOSS is undergoing a crisis.

And while I don’t want to discount any of these as real issues, but I don’t buy the hype. So I decided to see if FOSS was truly dead, and in good FOSS fashion, decided to fork a project. Now, this narrative is built backwards: I didn’t fork the project because I wanted to prove a point. In fact, this all started because I had a concrete need, I went out and searched for alternatives, and in the process ended up showing that FOSS is not dead, it just smells funny.

HiveDAV, a scheduling tool#

Around 10 months ago, I was looking for a solution to a problem I had. I wanted to have a way to make scheduling a meeting with me as simple as possible. Now, there are a few platforms that allow you to do that, and even some projects which allow you to self-host an instance. But none of the ones I found really fit what I wanted: a simple solution, with a permissive software license, and that I could easily deploy. This was until I found HiveDAV.

The interface was clean, the backend was written in Golang, without too many external dependencies, and under the MIT license. The fact that I could integrate it with the infrastructure that I already had (namely, I self-hosted my own calendars, that make use of the CalDAV standard) was very important. Also, the project boasted curl-ability as a feature, and that did tickle me nerd brain - scheduling on the terminal? Amazing! Unfortunately, the project’s development seemed to have slowed down considerably. Now, the codebase itself seemed quite good, but there were a few things that bothered me about the current implementation, and some features I saw that were missing.

This is the point where FOSS becomes very important. And at the time I truly looked at starting to contribute patches to the original project, but this is a model that I don’t necessarily agree with in all situations. It makes a lot of sense when you found a bug and want to contribute a fix; but when you want to change the software’s behavior, it becomes a bit harder to justify. I think it was Mr. Hipp, the creator or SQLite, that described this process of opening pull requests with contributions that expand the original scope of a project as passing on the burden of maintainability to the original developers. And since what I had in mind would considerably change the codebase, I thought that it was time for a fork. So, I forked HiveDAV.

Part 1: the non-negotiables#

The first commit I made to my fork is also the one I regret the most. And the reason is simple: I was still missing some software development chops, and just bundled a bunch of changes in the same commit. This is not okay, and something that I’ve learned to correct with time. Also, I probably shouldn’t start a section with a failure, but this was the first thing I noticed when I started doing the retrospective on this fork.

In that commit, there’s a lot of things that I felt were extremely needed before I could even start testing it properly. The first one was the implementation of a basic rate-limiting system. After all, this was going to be an internet-facing application, that could fire e-mails “at will”, and I’d very much like to avoid having my e-mail servers being part of a spam network. The code itself is really simple:

// Allow checks if a request is allowed under the rate limit
	func (rl *RateLimiter) Allow() bool {
		rl.mu.Lock()
		defer rl.mu.Unlock()
	
		now := time.Now()
		// Remove old requests outside the time window
		var validRequests []time.Time
		for _, req := range rl.requests {
			if now.Sub(req) < rl.window {
				validRequests = append(validRequests, req)
			}
		}
		rl.requests = validRequests
	
		// Check if we can add another request
		if len(rl.requests) >= rl.maxRequests {
			return false
		}
	
		// Add current request
		rl.requests = append(rl.requests, now)
		return true
	}

Which we then use to wrap the HTTP calls in a middleware for serving:

// checkRateLimit checks if the client IP is within rate limits for a specific endpoint type
func (s *Server) checkRateLimit(req *http.Request, maxRequests int, window time.Duration, endpointType string) bool {
	clientIP := s.getClientIP(req)
	key := fmt.Sprintf("%s:%s", endpointType, clientIP)
	
	s.mu.Lock()
	if s.rateLimiter == nil {
		s.rateLimiter = make(map[string]*RateLimiter)
	}
	
	if _, exists := s.rateLimiter[key]; !exists {
		s.rateLimiter[key] = &RateLimiter{
			requests:    make([]time.Time, 0),
			maxRequests: maxRequests,
			window:      window,
		}
	}
	limiter := s.rateLimiter[key]
	s.mu.Unlock()
	
	return limiter.Allow()
}
	
// rateLimitMiddleware wraps an httprouter.Handle with rate limiting
func (s *Server) rateLimitMiddleware(maxRequests int, window time.Duration, endpointType string, next httprouter.Handle) httprouter.Handle {
	return func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
		if !s.checkRateLimit(req, maxRequests, window, endpointType) {
			clientIP := s.getClientIP(req)
			log.Printf("Rate limit exceeded for %s endpoint from IP: %s", endpointType, clientIP)
			
			w.Header().Set("Content-Type", "text/plain")
			w.Header().Set("Retry-After", fmt.Sprintf("%.0f", window.Seconds()))
			http.Error(w, fmt.Sprintf("Rate limit exceeded. Maximum %d requests per %v allowed.", maxRequests, window), http.StatusTooManyRequests)
			return
		}
		
		// Rate limit passed, call the original handler
		next(w, req, ps)
	}
}

At this point, I was slightly more confident, but still felt that the backend needed a few more layers of protection, so I added a simple honeypot, as well as a lot of validation to all the input fields, e.g.:

// Honeypot validation - reject if the hidden field is filled
if req.FormValue("website") != "" {
	log.Printf("Automated request detected from IP: %s\n", req.RemoteAddr)
	http.Error(w, "Automated requests not allowed", http.StatusBadRequest)
	return
}
// Email format validation
emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
if !emailRegex.MatchString(mail) {
	http.Error(w, "Invalid email format", http.StatusBadRequest)
	return
}

// SMTP header injection protection - prevent CRLF injection in email and message
if strings.ContainsAny(mail, "\r\n") {
	log.Printf("SMTP header injection attempt detected in email field from IP: %s", s.getClientIP(req))
	http.Error(w, "Invalid email format", http.StatusBadRequest)
	return
}
		
if strings.ContainsAny(msg, "\r\n") {
	log.Printf("SMTP header injection attempt detected in message field from IP: %s", s.getClientIP(req))
	http.Error(w, "Invalid message format", http.StatusBadRequest)
	return
}

All of this, together with some injection protection and TLS fixes, made me relatively confident that this was now in a shape that I could use, and I started dogfooding: I deployed https://schedule.excipio.tech/ to help me manage my bookings. To help me with this, I also created a simple nginx.conf and a systemd unit file. And it was live and working! But the work had barely started.

Part 2: deployments, and more fixes#

First, the bugs I could see#

The good thing about dogfooding is that it allows you to feel the pain of a user. The bad thing about dogfooding is that sometimes the food tastes… not very tasty.

The first real bug I fixed was easy to spot, but had it happen in production, it would’ve been a hard pill to swallow. Look at this code:

res, err := http.DefaultClient.Do(req)
defer res.Body.Close()
if err != nil {
	return "", err
}

Few expressions bring the fear of God into the soul of a programmer as nil pointer dereference. And this is exactly what was happening here: we’re deferring the call to Close() the Body, before checking if there was an error. So, in effect, that deferred call could be made on a nil pointer, and this is a big no-no, as it’s bound to crash your server. Thankfully the fix is as simple as inverting the order: first, we check for errors, and then we defer the call to Close().

Now, I’m talking about this bug because this is the danger that any software developer faces, either on their own code or when taking over a codebase that’s not theirs. And sometimes things that might seem correct, end up being catastrophic. It’s literally just changing the position of a single line, there was nothing else done. And yet, this ended up avoiding a bug with very serious consequences for the stability of the software.

Still, a few more fixes were needed at this point: from wrapping up errors, to adding CSRF tokens, and adding trusted proxies, greatly improving long-term memory consumption and finally handling separate templates, things were shaping up quite well. But there was two last things I needed to do before I could consider myself happy with this phase: one, making deployments as easy as possible; and two, revamping the frontend.

Then, there was HTMX#

The frontend was something that I was excited to revamp. And it’s not that the original one looked bad, but for a while I have had an itch to experiment with HTMX, and this made the perfect opportunity to try and develop some skills (i.e., frontend) that I had never really exercised before other than some basic HTML and CSS.

HTMX seemed like a good choice for what I wanted to build. For one, I loved the fact that it integrates perfectly with Go’s html/template package; with server-side rendering being a strong point, it just made sense to unite both. Also, I really like the idea of having the server owning all the state, which for a simple app such as HiveDAV makes the most sense. With this combo, I could then just make use of Go’s embed package and pack the whole application into a single binary. This was exactly what I was looking for.

As an example, the whole calendar view is nothing more that a 80-line HTML file with some templating sprinkled in, which is kind of amazing considering that we actually have an interactive page, e.g.:

<button type="button" class="calendar-nav__btn calendar-nav__btn--primary"
        hx-get="/week/{{ .CurrentYear }}/{{ .CurrentWeek }}"
        hx-target="#calendar-section"
        hx-swap="innerHTML"
        hx-push-url="/week/{{ .CurrentYear }}/{{ .CurrentWeek }}"
        aria-label="Go to current week">
  Today
</button>

Single binary, portable deployments#

Now, this is what the infrastructure-loving parts of my brain were really looking forward to, the true objective I strove towards: single binary deployments; and if they can be portable, even better. Here, there were three main things that I had to work on. The first one, we already talked about: revamping the frontend was necessary, due to how the app was expected to interact with the existing HTML/CSS combo. With that out of the way, there were two other things that needed to be integrated.

The first one had to do with making use of Go’s embed package, which is truly a godsend for this purpose. With it, I could just pack the whole frontend into the final binary. And all it took was a +12 line code change. And while we might lose one or two niceties, such as on-disk template overrides for branding, what this brings to the table more than makes up for it.

The second part was even simpler: substituting mattn/go-sqlite3 for modernc.org/sqlite. The changes on the codebase itself were kept to changing 3 lines. But with this, we could eliminate the CGO requirement for the sqlite library, which does make cross-compilation a breeze. Portability all the way!

Part 3: adding features#

The work we described until now was centered around making the core application more stable, as well as adding a better user interface and niceties for the sysadmin that’s deploying this app. That’s all very important, but the truth is that there were quite some features that needed to be added until I was satisfied with the feature set of HiveDAV, and those were also a main motivation for forking the project.

So, in no specific order, these are some important nice-to-haves for a scheduling software:

What about tests, I can hear you ask. We do have tests! 65+ tests, last time I checked, that cover all the important parts of the app: from availability, to the handlers, CSRF, ICS folding, client IP and rate-limiting, and a few more. I went as far as making coverage checking an integral part of the test suite: if we ever fall below 50%, the CI/CD will fail. Talk about a contract with future me!

And finally, the piece de resistance of all my projects, the completely self-hosted CI/CD pipeline. And when I say completely self-hosted, I really mean it: I even went through the effort of writing my own actions, so that I wouldn’t even need to depend on the ones that are published on GitHub’s or Gitea’s platform.

Part 4: Refactoring and release#

The last couple week has been quite busy, as I wanted to finally cut a v2.0.0 release, and make this project publicly available. I had been running this for myself and a client for the past several months, so I’m relatively comfortable with how the software performs. But there were some parts of it I wasn’t completely comfortable with.

One example was the server.go file. A veritable monolith that I inherited and ended up making worse. Until last week, it clocked in at almost 2,000 lines of code. And while there isn’t anything wrong with it, it was becoming hard to reason about it, especially as I kept tacking on more and more features. Now, we might say that it’s also hard to reason about the 12 new files I had to create and that now house almost all the features I had before on server.go (which ended up being ~10% of the original size). But I ended up choosing an approach that privileged separation of concerns, and I don’t regret it, at least not for now.

And in some sense, I’m glad I went through this process, because through that I ended up realizing I had a very nasty TOCTOU bug latent in the code. The funny thing is that the comment already warned me about it, but the fix was never implemented. Look at the original code:

// Use a transaction to prevent TOCTOU race condition (double booking)
// BEGIN IMMEDIATE acquires a write lock immediately in SQLite
tx, err := s.db.Begin()
if err != nil {
	log.Printf("Error starting transaction: %v\n", err)
	http.Error(w, "Internal server error", http.StatusInternalServerError)
	return
}

But the truth is that, while this is safe in most situations, we’re still exposed to a situation where a SELECT+INSERT statement could create a race condition. And so the code needed to change:

// Get a dedicated connection for the entire transaction
conn, err := s.db.Conn(req.Context())
if err != nil {
	log.Printf("Error getting DB connection: %v\n", err)
	http.Error(w, "Internal server error", http.StatusInternalServerError)
	return
}
defer conn.Close()

// BEGIN IMMEDIATE acquires a reserved lock immediately in SQLite, preventing
// TOCTOU double-bookings. A deferred BEGIN would only lock on first write, allowing
// concurrent SELECT+INSERT
if _, err = conn.ExecContext(req.Context(), "BEGIN IMMEDIATE"); err != nil {
	log.Printf("Error starting transaction: %v\n", err)
	http.Error(w, "Internal server error", http.StatusInternalServerError)
	return
}

This new pattern is much more resilient, if only by virtue of not being broken under concurrent load: guaranteeing that we only have a single dedicated connection for the entire transaction is the only way to prevent those nasty TOCTOU bugs that applications such as these can suffer from.

Last but not least, there were a myriad of small changes I still enacted: modernizing the codebase to make use of more idiomatic Go patterns (like making use of range in for loops or matching errors with the errors.Is function); following the recommendations that came from go vet; or simply using proper context propagation in tests.

And at this point, I felt like I was ready to tag the v2.0.0 release! What a journey, eh?

Final considerations#

This has been an extremely rewarding project. I know that there is a lot of emphasis on “open source contributions”, but this creates the perverse incentive of having someone just do some hit-and-run PR on a random project to pad their CV. But this was a completely different approach: I decided to take ownership of someone else’s project, and build upon it. And ultimately, I’m very happy that I chose this path.

So, is FOSS dead? That’s clearly a NO from me. But it’s true that its shape is changing, as all communities do. It’s likely that we’re going to see fewer projects with open contributions, but more people willing to put in the effort into forking and maintaining. This is a perfectly valid strategy for FOSS to evolve (or go back to, if we consider the GitHub-led model isn’t the be-all-end-all of FOSS development). And I can only recommend that more people follow this path.

I guess there’s only one thing left: make use of https://schedule.excipio.tech to book a call with me, in case these are the types of projects you want to tackle, but need help with!