Cordelia: Learning Networking by Rebuilding LocalSend
A project to rekindle your passion for computers!
Preface
When I started building this project, I had never written a line of Go and my networking knowledge was limited (and still is, but not as much). This post is the story of what the project taught me, and it's not just how to write Go, but how network protocols work, TLS, and why certain design choices can make a difference in a practical scenario.
Why Rebuild LocalSend?
LocalSend is a great reference because it solves a real, constrained problem: two devices on the same LAN need to find each other and exchange data, with no cloud, no account, and no manual IP entry. That constraint demands some interesting decisions to be made. How do you find peers without a server? How do you move a file without loading it all into memory? How do you trust a device you have never seen before?
I also chose a CLI first because the terminal keeps the focus on networking logic instead of insignificant eye candy.
At the end of the day, Cordelia is a learning project. The CLI is but a thin wrapper, the core could later be reused by a GUI or a mobile app without changing the networking implementation.
Shaping the Project
Before writing any code, I had to create a vivid enough image inside my head. I actually wrote several prototypes to test out different features' implementations. At the end, they had to be merged like so:
- Identity: each instance generates a persistent fingerprint on first run. This is actually how Cordelia tells devices apart.
- Discovery: UDP multicast. Devices shout "I am here" every few seconds and listen for others doing the same, inside a network group.
- Registry: an in-memory list of recently seen peers, with automatic expiry.
- API: four HTTP endpoints over TLS: get your own info, list peers, receive a message and receive a file.
- Client: commands to probe, list peers and send text, send files. The client talks to the server (essentially a peer).
Everything runs on port 47777. The same binary acts as both server and client in a decentralized setting.
Identity: Being Someone on the Network
The first problem is identity. When Cordelia starts for the first time, it creates a device identity and saves it to disk.
func New(name string) (Identity, error) {
fp, err := randomFingerprint()
if err != nil {
return Identity{}, err
}
return Identity{Name: name, Fingerprint: fp}, nil
}
The fingerprint is 16 bytes of crypto/rand, hex-encoded into a 32-character string. However, this is not to be mistaken as a security feature. Two devices on the same LAN need to tell each other apart, for which a random string is good enough.
The identity is saved to ~/.config/cordelia/identity.json (or $XDG_CONFIG_HOME/cordelia/identity.json, depending on OS). Once created, it persists across restarts. The device name defaults to the OS hostname, which suffices for my toy project.
Discovery: UDP Multicast
This is where the first networking concept is introduced. The problem: two devices on the same LAN need to find each other, but neither knows the other's IP address. One could use broadcast, but multicast is cleaner.
Cordelia uses multicast group 239.255.77.77 on port 47777. Every instance joins this group and does two things:
- Announce: send a JSON packet every 3 seconds to the multicast group
- Listen: read incoming multicast packets and parse announcements
The announcement is small:
type Announcement struct {
Name string `json:"name"`
Fingerprint string `json:"fingerprint"`
CertFingerprint string `json:"cert_fingerprint"`
TCPPort int `json:"tcp_port"`
}
That is the entire discovery protocol. Simple, right?
The listener side filters out its own announcements by matching its own fingerprint and feeds new peers into the registry:
if ann.Fingerprint == self.Fingerprint {
continue
}
reg.Update(ann.Name, ann.Fingerprint, ann.CertFingerprint, src.IP.String(), ann.TCPPort)
The src.IP from the UDP packet is the peer's actual IP address. This is a key insight: multicast gives you both the announcement data and the sender's address, which eliminates the need for a separate "what is your IP?" step.
The Peer Registry: Knowing Who Is Around
A peer can join the network, announce itself, and might leave without warning. The registry handles this with TTL-based expiry.
type Registry struct {
mu sync.Mutex
peers map[string]Peer
ttl time.Duration
}
Peers are stored in a map[string]Peer, keyed by fingerprint. Every time a peer announces itself, its LastSeen timestamp updates. A background goroutine sweeps the map every 3 seconds and removes any peer whose LastSeen is older than the TTL (default 10 seconds).
The registry is protected by a sync.Mutex because multiple goroutines access it: the discovery listener writes to it, the sweep goroutine deletes from it, and the HTTP handlers read from it. Without the mutex, I get data races, and Go's race detector will catch them.
The tests prove this works:
func TestRegistryConcurrent(t *testing.T) {
r := New(10 * time.Second)
done := make(chan struct{})
go func() {
for range 100 {
r.Update("alice", "fp1", "cert1", "192.168.1.2", 47777)
}
close(done)
}()
for range 100 {
_ = r.Snapshot()
r.Sweep()
}
<-done
}
100 concurrent updates, 100 concurrent reads and sweeps. The race detector passes. This is thanks to sync.Mutex's correctness under concurrency without complex synchronization logic.
The HTTP API: Four Endpoints
The server is a standard net/http multiplexer with four routes:
GET /v1/info: returns the local identity as JSONGET /v1/peers: returns the current peer listPOST /v1/message: receives a text messagePOST /v1/upload: receives a file via multipart upload
Each route is a handler function that returns http.HandlerFunc. This is a Go pattern: the handler captures the registry, identity, or download directory it needs, and returns a function that handles individual requests.
mux.HandleFunc("GET /v1/peers", server.PeersHandler(reg))
The upload handler is the most interesting. It receives a multipart form, streams the file to disk, computes a SHA-256 checksum, and returns it in the X-Checksum-Sha256 header:
h := sha256.New()
if _, err := io.Copy(io.MultiWriter(dest, h), file); err != nil {
http.Error(w, "failed to save", http.StatusInternalServerError)
return
}
sum := hex.EncodeToString(h.Sum(nil))
w.Header().Set("X-Checksum-Sha256", sum)
In addition, the collision-safe naming handles duplicate filenames:
func uniquePath(dir, name string) string {
candidate := filepath.Join(dir, name)
if _, err := os.Stat(candidate); os.IsNotExist(err) {
return candidate
}
ext := filepath.Ext(name)
base := strings.TrimSuffix(name, ext)
for i := 1; ; i++ {
tried := fmt.Sprintf("%s (%d)%s", base, i, ext)
candidate = filepath.Join(dir, tried)
if _, err := os.Stat(candidate); os.IsNotExist(err) {
return candidate
}
}
}
photo.jpg becomes photo (1).jpg, photo (2).jpg, and so on. Simple, predictable, and I never lose a file to overwriting.
TLS: Encrypting the LAN
Plain HTTP on a LAN is fine for development. But a LAN is not a trusted network. Other devices can sniff traffic, and any device can join the same Wi-Fi. Cordelia uses TLS to encrypt all API traffic.
The certificate is self-signed ECDSA P-256, generated on first run and stored at ~/.config/cordelia/cert.pem and key.pem. The key file is created with 0600 permissions (owner read/write only).
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
Self-signed certs normally mean "accept on first use and trust forever." Cordelia does better: it uses certificate pinning. When you discover a peer via multicast, the announcement includes the peer's cert_fingerprint - a SHA-256 hash of their certificate. When you connect to that peer over TLS, the client verifies the presented certificate matches the announced fingerprint.
sum := sha256.Sum256(cert.Raw)
actual := hex.EncodeToString(sum[:])
if expectedFingerprint != "" && !strings.EqualFold(actual, expectedFingerprint) {
return fmt.Errorf("cert fingerprint mismatch: expected %s got %s", expectedFingerprint, actual)
}
This is Trust-On-First-Use (TOFU) with pinning. The first time you connect, you accept whatever cert is presented and remember its fingerprint. After that, any certificate mismatch is rejected. An attacker on the same LAN cannot intercept traffic after the first connection without you noticing.
The Client Side: Sending Things
The client commands are what make Cordelia useful. There are four main ones:
Probe
Checks if a specific address is running Cordelia:
func Probe(addr string) {
client := insecureClient(3 * time.Second)
res, err := client.Get(fmt.Sprintf("https://%s/v1/info", addr))
// ...
}
Useful for debugging: "is that machine actually running the server?"
Send Text
Sends a JSON message to a peer:
func SendText(addr, from, text string, localPort int) {
expected := expectedFingerprintForAddr(addr, localPort)
client := pinnedClient(expected, 3*time.Second)
// ...
}
The message is small (max 64 KiB), sent as {"from":"tux","text":"hello"}. The handler logs it and returns 204 No Content.
Send File
This is the most complex client command. The file is sent as a multipart upload, streamed through a progress reader:
type progressReader struct {
r io.Reader
total int64
sent int64
lastPct int
name string
}
The progress reader wraps the file and logs progress at 10% intervals. For a 100 MB file, you see:
sending photo.jpg: 10485760/104857600 (10%)
sending photo.jpg: 20971520/104857600 (20%)
...
sending photo.jpg: 104857600/104857600 (100%)
The file is never loaded into memory. The io.Pipe connects the multipart writer to the HTTP request body:
pr, pw := io.Pipe()
writer := multipart.NewWriter(pw)
go func() {
defer file.Close()
defer pw.Close()
defer writer.Close()
part, err := writer.CreateFormFile("file", filepath.Base(filePath))
// ...
io.Copy(part, progress)
}()
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("https://%s/v1/upload", addr), pr)
In this pattern, the goroutine writes multipart data into one end of the pipe and the HTTP client reads from the other end. The file never exists in full in memory. One can send a 1 GB file on a device with 256 MB of RAM.
Interactive Peer Picker
When you run send-text "hello" without specifying a host, Cordelia shows discovered peers and asks you to pick one:
func PickPeer(localPort int) (string, error) {
peers, err := ListPeers(localPort)
// ...
for i, peer := range peers {
fmt.Printf("[%d] %s [%s] %s:%d\n", i, peer.Name, peer.Fingerprint[:8], peer.Addr, peer.TCPPort)
}
fmt.Printf("pick peer [0-%d], default 0]: ", len(peers)-1)
// ...
}
This is a small feature that makes the tool feel alive. You do not need to know IP addresses. You see who is around and pick them by name.
Retry with Backoff
Network operations often fail due to various reasons. The client retries failed operations up to 3 times with exponential backoff:
for attempt := range 3 {
// ...
if err == nil && res.StatusCode == http.StatusNoContent {
break
}
// ...
if attempt < 2 {
backoff := time.Duration(500*(1<<attempt)) * time.Millisecond
log.Printf("retry %d/3 after %v: %v", attempt+1, backoff, err)
time.Sleep(backoff)
continue
}
log.Fatalf("send-text %s: %v", addr, lastErr)
}
The delays are 500ms, 1s, 2s. Short enough to feel responsive, long enough for transient failures to resolve. After 3 failures, the client gives up with a clear error message.
The client also distinguishes between transient errors (server 500, connection refused) and permanent errors (client 400, bad request). Permanent errors are not retried.
Graceful Shutdown
When you press Ctrl+C, Cordelia does not just die. It drains in-flight requests, closes connections cleanly, and logs what it is doing:
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
if err := server.ListenAndServeTLS("", ""); err != nil {
log.Fatal(err)
}
}()
<-ctx.Done()
log.Println("shutting down...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown error: %v", err)
}
The 5-second timeout prevents hanging forever if a client is misbehaving. signal.NotifyContext is the modern Go way to handle OS signals.
Configuration
Cordelia stores its config at ~/.config/cordelia/config.json:
{
"port": 47777,
"out_dir": "",
"ttl": "10s"
}
The config file is created with 0600 permissions. Flags override the config file, so cordelia -port 47778 always wins. The TTL controls how long peers stay in the registry before being swept (default 10 seconds).
The config system is deliberately minimal.
Cross-Compilation and CI/CD
Cordelia has zero third-party dependencies. The go.mod file has only one line beyond the module declaration:
go 1.27.0
Only using the Go standard library means cross-compilation is trivial:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "-s -w -X main.version=v1.0.0" -o dist/cordelia-linux-amd64 ./cmd/cordelia
CGO_ENABLED=0 ensures pure Go compilation. The same command works for every target:
| OS | Arch | Binary |
|---|---|---|
| Linux | amd64 | cordelia-linux-amd64 |
| Linux | arm64 | cordelia-linux-arm64 |
| macOS | amd64 | cordelia-darwin-amd64 |
| macOS | arm64 | cordelia-darwin-arm64 |
| Windows | amd64 | cordelia-windows-amd64.exe |
| Windows | arm64 | cordelia-windows-arm64.exe |
The release workflow runs on v* tag push. GitHub Actions builds all six targets, generates checksums, and publishes them to the GitHub Releases page:
on:
push:
tags:
- "v*"
To cut a release: git tag v1.0.0 && git push origin v1.0.0. That is it. The CI takes care of the rest.
The CI pipeline also runs on every push and PR to main:
- name: Vet
run: go vet ./...
- name: Test with race
run: go test -race ./...
go vet catches common mistakes. go test -race catches data races. Both must pass before a merge.
Testing
The tests cover the core data structures without needing a running server:
- Registry: update, snapshot, sweep, and concurrent access. The concurrent test is the most important; it proves the mutex works under pressure.
- Config: defaults, TTL parsing, save/load config, file permissions.
- Certs: creation, reuse, fingerprint generation, error handling.
Each test uses t.TempDir() for isolation. No shared state between tests and therefore, no cleanup needed.
The tests are fast, they complete in milliseconds which is important because they run on every push.
Project Structure
cmd/cordelia/ -> entry point, flag parsing, wiring
internal/
certs/ -> self-signed cert generation and fingerprint
client/ -> probe, peers, send-text, send-file, retry, TLS pinning
config/ -> persistent config file
discovery/ -> UDP multicast announce and listen
identity/ -> fingerprint generation and persistence
registry/ -> peer registry with TTL
server/ -> HTTP handlers, download directory, upload limits
The internal/ directory is a Go convention: packages inside internal/ cannot be imported by code outside the module. This enforces the boundary between "public API" and "implementation detail." The client, server, and discovery packages are internal. The only public surface is the binary itself.
The cmd/cordelia/main.go file is the wiring layer. It parses flags, loads config, creates identity, sets up the registry, starts discovery goroutines, configures the HTTP mux, and starts the server. However, it delegates everything to the internal packages.
Closing Thoughts
The Go standard library truly gives you everything you need. net/http for the API, net for UDP multicast, crypto/tls for encryption, crypto/sha256 for integrity, encoding/json for serialization, sync.Mutex for concurrency, and probably much more.
The hardest part was not any individual feature. It was making the features work together. Discovery feeds the registry. The registry feeds the API. The API feeds the client. The client uses TLS, which uses the cert fingerprint from discovery. Every piece depends on the others.
The second hardest part was deciding what to leave out. Every feature I added: TLS, checksums, retry, graceful shutdown - made the tool better, but also made the codebase larger. The discipline was knowing when to stop. I decided v1.0.0 is the line.
// comments