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.