No router library, no framework. Just net/http and a few patterns from uptimy/agent worth copying: method-aware routes, a typed context key, a login that does the same expensive password work for unknown users, and one blank import that saves you a production bug.
· 7 min read

How uptimy/agent Builds Its HTTP API with net/http, ServeMux Patterns, and Context Keys


Most “here’s a real Go codebase” posts turn into a tour of somebody’s favourite router library. uptimy/agent at the commit reviewed for this post doesn’t have one.

It’s a self-hosted uptime monitor that ships as a single binary: HTTP checks, database checks, ping, DNS, Kubernetes monitors, cron monitoring via heartbeats, a status page, and alerting. It is positioned as an Uptime Kuma alternative. Its internal/api package shows five concrete choices built around net/http and *http.ServeMux.

Those decisions: method-aware route patterns, a server struct instead of package globals, a context key that cannot collide, a login path that doesn’t leak which usernames exist, and a blank import that prevents a bug you’ll only ever see in production.

Routes use method and wildcard patterns, no router library

Go 1.22 taught http.ServeMux to understand methods and path wildcards. internal/api/badges.go registers its routes like this:

func (s *Server) badgeRoutes(mux *http.ServeMux) {
	mux.HandleFunc("GET /badge/status.svg", s.overallBadge)
	mux.HandleFunc("GET /badge/{id}/status.svg", s.statusBadge)
	mux.HandleFunc("GET /badge/{id}/uptime.svg", s.uptimeBadge)
}

The pattern string carries the method and the path variable. Reading the variable back out is r.PathValue, which badgeMonitor in the same file does:

id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)

Most routes still live in the central table in internal/api/server.go. The public ping and badge endpoints are the exceptions: pingRoutes and badgeRoutes keep those small groups beside their handlers, while local auth and admin helpers make the access policy visible in the main table. That is a useful middle ground: you can scan one file for most of the API surface without repeating middleware wrappers on every line.

The weak spot: r.PathValue returns a string, and an unknown name returns "". Nothing checks at compile time that {id} in the pattern matches "id" in the handler. Rename the wildcard and the handler quietly starts parsing an empty string, which strconv.ParseInt will happily turn into a zero you ignored with _. A test per route pattern is cheap insurance against a bug that reads as “monitor 0 not found.”

We covered this routing style from the ground up in Build a REST API in Go with net/http. The net/http docs have the precedence rules for overlapping patterns.

Handlers hang off a Server struct, not package globals

The application handlers in internal/api are methods on *Server. cmd/uptimy-agent/main.go builds that struct once, with dependencies passed in explicitly:

srv := &api.Server{
	Config: cfg, Version: version, Store: st, Scheduler: sched,
	Sender: sender, Hub: hub, Log: log, Watchdog: watchdog,
	KubeAvailable: kc != nil, Discovery: disc,
}

KubeAvailable: kc != nil is the detail to copy. kube.NewInCluster() returns nil when the process isn’t running inside a cluster, so the API layer receives a plain bool rather than probing for Kubernetes on every request. Capability detection happens once, at startup, in the place that already knows the answer. Handlers read a field.

This is dependency injection by struct, nothing clever. The dependencies are concrete types rather than interfaces, so tests use a temporary store and real lightweight collaborators rather than a fake store, but they can still construct a Server directly and exercise its handler.

A context key type that cannot collide

The authenticated user travels from middleware to handler through the request context. internal/api/auth.go uses the safe form:

type userKey struct{}

func currentUser(r *http.Request) store.User {
	u, _ := r.Context().Value(userKey{}).(store.User)
	return u
}

An unexported empty struct type means no other package can produce an equal key, even by accident. A string key like "user" can collide with a library that picks the same word. The standard context guidance recommends a package-defined type for exactly this reason, and struct{} avoids an allocation when the key is stored in an interface.

The comma-ok assertion is doing the second half of the work. If the value is absent or has the wrong type, currentUser returns the zero store.User instead of panicking. That is a defensive fallback, not the authentication check: the protected routes are wrapped by requireAuth before these handlers run. If contexts are new to you, Context in Go: Cancellation, Timeouts, and Values covers the basics.

The login path still runs bcrypt for unknown usernames

The login handler defines a dummy bcrypt hash:

// dummyHash makes a login for an unknown username cost the same as a wrong
// password, so response timing doesn't reveal which usernames exist.
var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("uptimy-agent-dummy"), bcrypt.DefaultCost)

bcrypt is deliberately expensive. Skip the comparison when the user doesn’t exist and that branch avoids almost all of the password-checking work. Comparing against a precomputed throwaway hash makes both branches perform one bcrypt comparison at the same cost. It does not make the complete requests constant-time—the database lookup and surrounding work still differ—but it removes the largest timing signal.

A minimal version looks like this:

package main

import (
	"fmt"

	"golang.org/x/crypto/bcrypt"
)

var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("placeholder"), bcrypt.DefaultCost)

type user struct{ passwordHash []byte }

// verify always performs one bcrypt comparison, whether or not the user exists.
func verify(u *user, password string) bool {
	hash := dummyHash
	if u != nil {
		hash = u.passwordHash
	}
	err := bcrypt.CompareHashAndPassword(hash, []byte(password))
	return u != nil && err == nil
}

func main() {
	fmt.Println(verify(nil, "hunter2")) // false, but still costs a full bcrypt compare
}

The u != nil && guard is essential. Without it, submitting the dummy password would make the function report success for a user that does not exist.

Session tokens are also stored indirectly. hashToken runs the cookie value through SHA-256 before the store lookup, so the sessions table does not contain the raw cookie value needed for replay.

Embedding the domain type into API responses

internal/api/healthchecks.go builds its response type by embedding the monitor and bolting computed fields on top:

type healthcheckSummary struct {
	monitor.Monitor
	Status        monitor.Status   `json:"status"`
	Target        string           `json:"target"` // password masked
	LastResult    *monitor.Result  `json:"last_result"`
	Recent        []monitor.Result `json:"recent"`
	Uptime24h     *float64         `json:"uptime_24h"`
	AvgLatency24h *float64         `json:"avg_latency_24h"`
	InMaintenance bool             `json:"in_maintenance"`
}

An embedded struct with no JSON tag gets flattened by encoding/json, so the monitor’s fields land at the top level of the response next to status and uptime_24h. That avoids restating every monitor field in a separate DTO, but it also couples the API schema to the domain type.

The pointer types preserve an important distinction. *float64 for Uptime24h separates “no data yet” (null) from “0% uptime” (0), and internal/api/badges.go branches on the same distinction: a nil ratio renders a grey “no data” badge rather than a red zero. A plain float64 could not represent those two states separately.

internal/api/heartbeats.go reuses the embedding trick for cron monitoring, where heartbeatSummary adds Schedule, Tracking, and Stats30d to the embedded monitor.

There is a caveat: if the outer struct declares a field with the same JSON name as an embedded one, the shallower field wins without an error. Changes to the domain type therefore need an API-schema review.

Keeping named time zones available in minimal images

From cmd/uptimy-agent/main.go:

_ "time/tzdata" // heartbeat cron schedules need time zones, and minimal images lack the database

time.LoadLocation normally consults an installed IANA time-zone database. Distroless and scratch images may not ship one, so LoadLocation("Europe/London") can fail in the container even though it works on your laptop. Importing time/tzdata for its side effects adds an embedded fallback. The official package documentation puts the binary-size cost at about 450 KB. If your service loads named time zones and ships in a minimal container, that is usually a straightforward trade.

Startup and shutdown

run creates its context with signal.NotifyContext:

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

That context goes to the scheduler and four long-running workers: Kubernetes discovery when enabled, retention pruning, the Uptimy watchdog, and Uptimy maintenance. A SIGTERM cancels those components and also releases the main loop to call http.Server.Shutdown. We broke the pattern down in Graceful Shutdown for Go HTTP Servers; here the same cancellation path coordinates the listener and the background work.

The admin bootstrap is similarly explicit. api.EnsureDefaultAdmin in internal/api/auth.go generates a random password on first start, returns it so main can log it once, and sets MustChangePassword: true. If ADMIN_PASSWORD is later set, the function creates the named admin if necessary, promotes an existing user to admin, and resets the stored password when it differs or still requires a change. Only that reset path calls DeleteUserSessions to revoke old sessions. Env-driven deployments stay in sync without shipping a documented default password.

The probes themselves live in internal/checks/http.go, internal/checks/database.go, and internal/checks/kubernetes.go, wired into the scheduler by checks.New(kc) in main.go. That is the next useful layer to inspect: it shows how the scheduler presents one interface to checks with very different targets.