Build a P2P Disaster Mesh Network
Build a decentralized mesh network with Go, CRDTs, and a real-time web UI.
Introduction
30 Second Summary
When disasters strike, communication infrastructure is often the first thing to fail. Imagine being able to send emergency messages to everyone nearby without needing the internet, a cell tower, or any central server.
In this project, you will build a decentralized peer-to-peer disaster mesh network called "RescueNet" using Go. Multiple devices on the same WiFi discover each other automatically, exchange SOS messages, and sync all data even after network interruptions.
What You'll Build
A fully decentralized P2P mesh network in Go with a web-based UI, GossipSub messaging, mDNS peer discovery, and Merkle-CRDT state replication.
By the end of this project, you'll have:
- A go-libp2p node with automatic mDNS discovery and GossipSub mesh networking that works without internet access.
- A Merkle-CRDT replicated datastore that syncs SOS messages across all peers and resolves conflicts automatically after network partitions.
- A real-time web UI with WebSocket push updates, accessible from any device on the local network.
- A Docker-containerized deployment for one-command testing with multiple nodes.
- Secret Mission: Add a keyword-based SOS priority classifier that flags critical messages using pure Go string analysis.
Are there any prerequisites?
This project assumes basic Go knowledge (structs, interfaces, goroutines, channels) and familiarity with go mod. You should also be comfortable with HTML/CSS/JS basics and running terminal commands.
Initialize the Git Repository and Project Structure
Every well-structured Go project starts with a clean Git repository and clear folder separation. RescueNet has multiple subsystems: P2P networking, CRDT replication, and a web server. Each needs its own package to keep the codebase maintainable as it grows.
In this step, you'll create the project directory, initialize Git and Go modules, set up the folder structure, and install the six core dependencies that power the mesh network.
In this step, get ready to:
- Create the project directory with Git version control and a proper .gitignore.
- Initialize the Go module and set up the folder structure.
- Install the core P2P and web dependencies.
Create the project directory and initialize Git
A dedicated project folder keeps all of RescueNet's files together. Initializing Git from the start means every change is tracked from the very beginning.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder, move into it, and initialize a Git repository by running these commands:
mkdir rescuenet
cd rescuenet
git init
You should see a message like Initialized empty Git repository in .../rescuenet/.git/ confirming your repository is ready.
Don't see the initialized message?
Make sure you have Git installed by running git --version in your terminal. If the command is not found, install Git before continuing.
Help me troubleshoot my git init issue
Next, you need a .gitignore file to keep compiled binaries, data directories, and environment secrets out of version control.
- Create a new file called .gitignore in the root of your rescuenet folder. Open it in your code editor and paste the following content:
# Binaries
rescuenet
*.exe
*.out
# Vendor
vendor/
# Data directories
.rescuenet-data/
*.pebble/
# Environment
.env
# Build artifacts
dist/
What does this file do?
The .gitignore tells Git which files to never track. Compiled binaries (rescuenet, *.exe) change on every build and waste space in your repo.
The .rescuenet-data/ and *.pebble/ entries exclude the local Pebble database files that each node creates at runtime. The .env entry keeps secrets out of your repository.
- Save .gitignore.
- Confirm the file exists by running:
ls -a
You should see .git and .gitignore listed in the output.
Set up the Go module and folder structure
Go uses modules to manage dependencies. Initializing a module creates a go.mod file that tracks every external package your project needs.
- Initialize your Go module by running this command:
go mod init github.com/[[GITHUB_USER="your-github-username"]]/rescuenet
What is the module path?
The module path (github.com/<username>/rescuenet) is how other Go programs would import your code. It follows the convention of using your GitHub repository URL as a globally unique identifier.
If you do not have a GitHub account yet, use any placeholder like github.com/myuser/rescuenet. You can update it later.
You should see a new go.mod file appear in your rescuenet folder.
RescueNet's code is split into packages that mirror its architecture. The cmd/ directory holds the entry point. The internal/ directory holds packages that only this project can import. The web/ directory holds the browser-facing HTML, CSS, and JavaScript.
- Create the folder structure by running this command:
mkdir -p cmd/rescuenet internal/node internal/web web
What do these folders do?
- cmd/rescuenet/ holds the main.go entry point that starts the node.
- internal/node/ holds all P2P networking logic (libp2p host, GossipSub, mDNS, CRDT).
- internal/web/ holds the HTTP server and WebSocket handler.
- web/ holds the static frontend files (HTML, CSS, JS) that the server delivers to browsers.
- Verify the folders were created by running:
find . -type d -not -path './.git/*' -not -path './.git'
You should see ./cmd/rescuenet, ./internal/node, ./internal/web, and ./web in the output.
Missing folders?
Check that you ran the mkdir -p command from inside the rescuenet directory. Run pwd to confirm your current location ends with /rescuenet.
Help me fix my folder structure
Install the core dependencies
RescueNet relies on six external Go packages. These provide the P2P transport layer, pub/sub messaging, CRDT replication, persistent storage, and WebSocket support.
- Install all six core dependencies by running these commands:
go get github.com/libp2p/go-libp2p
go get github.com/libp2p/go-libp2p-pubsub
go get github.com/ipfs/go-ds-crdt
go get github.com/ipfs/ipfs-lite
go get github.com/ipfs/go-ds-pebble
go get github.com/gorilla/websocket
What are these packages?
- go-libp2p creates the P2P host that listens for connections and manages peer identities.
- go-libp2p-pubsub provides the GossipSub protocol for efficient message propagation across the mesh.
- go-ds-crdt implements the Merkle-CRDT datastore that handles conflict-free replication.
- ipfs-lite provides the DAGService for Merkle DAG exchange between peers.
- go-ds-pebble gives each node a persistent local key-value store backed by Pebble.
- gorilla/websocket powers real-time browser updates from the Go backend.
Each go get command downloads the package and adds it to your go.mod file. This takes a minute or two as Go resolves all transitive dependencies.
- Verify the dependencies were added by running:
cat go.mod
You should see a require block listing all six packages with resolved version numbers. A go.sum file also appeared. It stores cryptographic checksums for every dependency.
Seeing network or timeout errors?
The go get command requires internet access to download packages from the Go module proxy. Make sure your network connection is stable.
If you see a connection refused or timeout error, try setting the Go proxy explicitly by running go env -w GOPROXY=https://proxy.golang.org,direct and then re-running the failed go get command.
Help me fix my go get errors
✔️ Awesome, I've got everything!
Great. Double check you have saved your .gitignore file and that your go.mod shows all six dependencies.
ⓧ I'd like to double check the full code
Here is what your .gitignore should look like:
# Binaries
rescuenet
*.exe
*.out
# Vendor
vendor/
# Data directories
.rescuenet-data/
*.pebble/
# Environment
.env
# Build artifacts
dist/
Here is what your go.mod should look like (your exact version numbers may differ slightly):
module github.com/[[GITHUB_USER="your-github-username"]]/rescuenet
go 1.22
require (
github.com/gorilla/websocket v1.5.1
github.com/ipfs/go-ds-crdt v0.5.1
github.com/ipfs/go-ds-pebble v0.3.1
github.com/ipfs/ipfs-lite v1.9.0
github.com/libp2p/go-libp2p v0.33.0
github.com/libp2p/go-libp2p-pubsub v0.11.0
)
Your project skeleton is ready. You have a clean Git repo, a proper .gitignore, Go modules configured, and all six core dependencies installed. Next up, you'll write the P2P node that creates a libp2p host, attaches GossipSub, and discovers peers on your local network.
Create the libp2p Host with GossipSub and mDNS
Your project structure is ready and all dependencies are installed. Now it is time to write the core networking layer that makes RescueNet a true peer-to-peer system.
Every RescueNet node needs three capabilities: a libp2p host that gives it an identity on the network, a GossipSub router for efficient message propagation, and mDNS discovery so peers on the same WiFi find each other without any configuration. In this step, you will build all three into a single reusable Node package.
In this step, get ready to:
- Define the Node struct that wraps the libp2p host, GossipSub router, and pub/sub topic.
- Implement an mDNS discovery handler that auto-connects to peers on the local network.
- Build a constructor that wires up the host, messaging, and discovery into a single component.
Define the node struct and discovery handler
A libp2p host is the fundamental building block of any P2P application. It generates a cryptographic identity (an Ed25519 keypair), listens for connections, and manages peer relationships.
You will create a Node struct that holds the host, a GossipSub instance for pub/sub messaging, and a topic that all nodes subscribe to. You will also define a discovery handler that automatically connects to any peer found via mDNS.
- Create the file internal/node/node.go by running this command from your rescuenet project folder:
touch internal/node/node.go
- Confirm the file exists by running:
ls internal/node/
You should see node.go in the output.
- Open internal/node/node.go in your editor and paste the following code:
package node
import (
"context"
"fmt"
libp2p "github.com/libp2p/go-libp2p"
"github.com/libp2p/go-libp2p/core/host"
"github.com/libp2p/go-libp2p/core/peer"
"github.com/libp2p/go-libp2p/p2p/discovery/mdns"
pubsub "github.com/libp2p/go-libp2p-pubsub"
)
// Service tag for mDNS peer discovery on the local network
const (
MDNSServiceTag = "rescuenet-mdns"
CRDTTopic = "rescuenet-crdt"
)
// Node wraps the libp2p host, GossipSub router, and pub/sub topic
type Node struct {
Host host.Host
PS *pubsub.PubSub
Topic *pubsub.Topic
}
// mdnsNotifee handles peer discovery events from mDNS
type mdnsNotifee struct {
h host.Host
}
// HandlePeerFound connects to any peer discovered on the local network
func (n *mdnsNotifee) HandlePeerFound(pi peer.AddrInfo) {
_ = n.h.Connect(context.Background(), pi)
}
What does this code do?
The Node struct holds three fields. Host is the libp2p host (your identity and connection manager). PS is the GossipSub router that handles pub/sub message propagation. Topic is the named channel all nodes subscribe to for CRDT updates.
The mdnsNotifee struct implements a single method: HandlePeerFound. Whenever mDNS discovers a new peer on the local WiFi, libp2p calls this method. It immediately tries to connect, so peers join the mesh without any manual configuration.
Why mDNS instead of a DHT?
A distributed hash table (DHT) requires bootstrap nodes on the internet. For a disaster scenario where internet access may be down, mDNS is the right choice. It broadcasts presence on the local network segment with zero configuration and zero external dependencies.
You won't see a change yet. The Go compiler will report unused-import errors because several packages (fmt, libp2p, mdns) are not used until the constructor function you will add next.
Build the NewNode constructor
The constructor function is where the three networking layers come together. It creates the libp2p host (with a fresh Ed25519 keypair and a random TCP port), attaches the GossipSub router, joins the CRDT topic, and starts mDNS discovery.
- In internal/node/node.go, add the NewNode function below the HandlePeerFound method:
// NewNode creates a libp2p host, attaches GossipSub, and starts mDNS discovery
func NewNode(ctx context.Context) (*Node, error) {
// Create a new libp2p host with a random TCP port and fresh Ed25519 identity
h, err := libp2p.New(libp2p.ListenAddrStrings("/ip4/0.0.0.0/tcp/0"))
if err != nil {
return nil, fmt.Errorf("create host: %w", err)
}
// Attach the GossipSub router for efficient pub/sub message propagation
ps, err := pubsub.NewGossipSub(ctx, h)
if err != nil {
return nil, fmt.Errorf("create gossipsub: %w", err)
}
// Join the shared CRDT topic so all nodes can exchange state updates
topic, err := ps.Join(CRDTTopic)
if err != nil {
return nil, fmt.Errorf("join topic: %w", err)
}
// Start mDNS discovery to find peers on the local network automatically
svc := mdns.NewMdnsService(h, MDNSServiceTag, &mdnsNotifee{h: h})
if err := svc.Start(); err != nil {
return nil, fmt.Errorf("start mdns: %w", err)
}
return &Node{Host: h, PS: ps, Topic: topic}, nil
}
What does this code do?
- libp2p.New() creates a host that listens on all interfaces (0.0.0.0) on a random available TCP port. Each run generates a fresh Ed25519 keypair, giving the node a unique peer ID.
- pubsub.NewGossipSub() attaches a GossipSub router to the host. GossipSub is a protocol that propagates messages through the mesh efficiently. Not every node needs a direct connection to every other node.
- ps.Join(CRDTTopic) subscribes the node to the rescuenet-crdt topic. The CRDT layer will use this topic to announce new data heads to all peers.
- mdns.NewMdnsService() broadcasts the node's presence on the local network. When another node responds, HandlePeerFound fires and establishes a connection automatically.
- Save internal/node/node.go.
- Verify the package compiles by running this command from your rescuenet project folder:
go build ./...
You should see no output. No output from go build means everything compiled successfully.
Seeing a compile error?
- If you see "imported and not used", make sure both the type definitions and the NewNode function are present in the file. Go rejects unused imports.
- If you see "cannot find package", confirm you ran the dependency installs in Step 1. Try running go mod tidy to resolve missing modules.
- If you see "undefined: pubsub" or similar, check that the import alias matches exactly. The line should read pubsub "github.com/libp2p/go-libp2p-pubsub".
Help me debug my go build error for the node package.
✔️ Awesome, I've got everything!
Great. Double check you have saved internal/node/node.go and that go build ./... produces no output.
ⓧ I'd like to double check the full code
package node
import (
"context"
"fmt"
libp2p "github.com/libp2p/go-libp2p"
"github.com/libp2p/go-libp2p/core/host"
"github.com/libp2p/go-libp2p/core/peer"
"github.com/libp2p/go-libp2p/p2p/discovery/mdns"
pubsub "github.com/libp2p/go-libp2p-pubsub"
)
const (
MDNSServiceTag = "rescuenet-mdns"
CRDTTopic = "rescuenet-crdt"
)
type Node struct {
Host host.Host
PS *pubsub.PubSub
Topic *pubsub.Topic
}
type mdnsNotifee struct {
h host.Host
}
func (n *mdnsNotifee) HandlePeerFound(pi peer.AddrInfo) {
_ = n.h.Connect(context.Background(), pi)
}
func NewNode(ctx context.Context) (*Node, error) {
h, err := libp2p.New(libp2p.ListenAddrStrings("/ip4/0.0.0.0/tcp/0"))
if err != nil {
return nil, fmt.Errorf("create host: %w", err)
}
ps, err := pubsub.NewGossipSub(ctx, h)
if err != nil {
return nil, fmt.Errorf("create gossipsub: %w", err)
}
topic, err := ps.Join(CRDTTopic)
if err != nil {
return nil, fmt.Errorf("join topic: %w", err)
}
svc := mdns.NewMdnsService(h, MDNSServiceTag, &mdnsNotifee{h: h})
if err := svc.Start(); err != nil {
return nil, fmt.Errorf("start mdns: %w", err)
}
return &Node{Host: h, PS: ps, Topic: topic}, nil
}
Your P2P node can now create its own identity, propagate messages through the mesh, and discover peers on the local network. Next up, you will wire up the Merkle-CRDT datastore that makes RescueNet's messages survive network partitions and converge automatically.
Build the Web UI and WebSocket Server
Your CRDT datastore is wired up and ready to replicate state across peers. Now you need a way for humans to actually interact with it.
In this step, you will build an HTTP server that serves a web frontend and exposes a WebSocket endpoint for real-time push updates. The server also provides REST-like API endpoints for submitting SOS messages, retrieving the message history, and listing connected peers.
In this step, get ready to:
- Create the HTTP server struct with WebSocket client tracking and a broadcast channel.
- Wire up WebSocket connections for real-time push updates from the CRDT layer.
- Implement REST API endpoints for SOS message submission, message retrieval, and peer listing.
Create the HTTP server and register routes
The server needs to track all active WebSocket clients so it can broadcast CRDT updates to every connected browser. A buffered channel acts as the bridge between the CRDT layer's PutHook callback and the WebSocket hub loop.
- Create a new file at internal/web/server.go and add the server scaffold with all imports, the Server struct, constructor, and Start() method by pasting the following code:
package web
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
"github.com/<user>/rescuenet/internal/node"
"github.com/gorilla/websocket"
"github.com/ipfs/go-datastore"
"github.com/ipfs/go-datastore/query"
)
// Allow WebSocket connections from any origin (for LAN access)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
// Message represents an SOS message stored in the CRDT datastore
type Message struct {
Key string `json:"key"`
Text string `json:"text"`
Timestamp string `json:"timestamp"`
}
// Server holds the HTTP/WebSocket server state
type Server struct {
node *node.Node
clients map[*websocket.Conn]bool
broadcast chan []byte
mu sync.Mutex
}
// NewServer creates a Server and wires the CRDT PutHook to the broadcast channel
func NewServer(n *node.Node) *Server {
s := &Server{
node: n,
clients: make(map[*websocket.Conn]bool),
broadcast: make(chan []byte, 64),
}
n.PutHook = func(key string, value []byte) {
s.broadcast <- value
}
return s
}
// Start launches the hub loop and HTTP server on port 8080
func (s *Server) Start() error {
go s.hubLoop()
http.Handle("/", http.FileServer(http.Dir("web")))
http.HandleFunc("/ws", s.handleWS)
http.HandleFunc("/api/sos", s.handleSOS)
http.HandleFunc("/api/messages", s.handleMessages)
http.HandleFunc("/api/peers", s.handlePeers)
fmt.Println("Web UI listening on http://0.0.0.0:8080")
return http.ListenAndServe("0.0.0.0:8080", nil)
}
What does this code do?
The Server struct holds a reference to the P2P node, a map of active WebSocket clients, a broadcast channel, and a mutex for safe concurrent access to the client map.
The NewServer() constructor wires the node's PutHook to push raw message bytes onto the broadcast channel every time the CRDT store receives a write (local or replicated from a peer).
The Start() method registers all HTTP routes, binds to 0.0.0.0:8080 so any device on the LAN can reach it, and launches the hub loop goroutine.
You will not see a change yet. The file references handler methods that do not exist. The next chunk adds them.
Implement the WebSocket hub and handler
The hub loop reads from the broadcast channel and fans out each message to every connected WebSocket client. The handler upgrades incoming HTTP connections to WebSocket and registers them in the client map.
- In internal/web/server.go, add the hubLoop() and handleWS() methods below the Start() function:
// hubLoop fans out broadcast messages to all connected WebSocket clients
func (s *Server) hubLoop() {
for msg := range s.broadcast {
s.mu.Lock()
for conn := range s.clients {
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
conn.Close()
delete(s.clients, conn)
}
}
s.mu.Unlock()
}
}
// handleWS upgrades an HTTP connection to WebSocket and registers the client
func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
s.mu.Lock()
s.clients[conn] = true
s.mu.Unlock()
defer func() {
s.mu.Lock()
delete(s.clients, conn)
s.mu.Unlock()
conn.Close()
}()
// Keep reading to detect disconnects
for {
if _, _, err := conn.ReadMessage(); err != nil {
break
}
}
}
What does this code do?
The hubLoop() goroutine runs forever, reading from the broadcast channel. For each message, it locks the client map and writes to every active WebSocket connection. If a write fails, the connection is closed and removed.
The handleWS() function upgrades the HTTP request to a WebSocket using the upgrader (which allows all origins for LAN access). It then enters a read loop that blocks until the client disconnects. This read loop is required by gorilla/websocket to detect connection closure.
The file still will not compile because the REST handlers are missing. The next chunk adds those.
Implement the REST API endpoints
The server needs three endpoints. One accepts new SOS messages and writes them into the CRDT store. The other two are read-only endpoints that query existing messages and list connected peers.
- In internal/web/server.go, add the three handler methods below handleWS():
// handleSOS accepts a new SOS message and writes it to the CRDT store
func (s *Server) handleSOS(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Message string `json:"message"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
// Use RFC3339Nano timestamp as unique key
ts := time.Now().UTC().Format(time.RFC3339Nano)
msg := Message{Key: "/messages/" + ts, Text: req.Message, Timestamp: ts}
v, _ := json.Marshal(msg)
key := datastore.NewKey(msg.Key)
if err := s.node.CRDT.Put(context.Background(), key, v); err != nil {
http.Error(w, "store error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
}
// handleMessages returns all SOS messages from the CRDT store
func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) {
results, err := s.node.CRDT.Query(context.Background(), query.Query{Prefix: "/messages/"})
if err != nil {
http.Error(w, "query error", http.StatusInternalServerError)
return
}
defer results.Close()
var msgs []Message
for result := range results.Next() {
var m Message
if err := json.Unmarshal(result.Value, &m); err == nil {
msgs = append(msgs, m)
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(msgs)
}
// handlePeers returns the list of connected P2P peer IDs
func (s *Server) handlePeers(w http.ResponseWriter, r *http.Request) {
peers := s.node.Host.Network().Peers()
ids := make([]string, len(peers))
for i, p := range peers {
ids[i] = p.String()
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(ids)
}
What does this code do?
The handleSOS() endpoint only accepts POST requests. It decodes a JSON body with a message field, generates a nanosecond-precision timestamp as a unique key, serializes the full message as JSON, and writes it to the CRDT store under /messages/<timestamp>. The PutHook fires automatically and pushes the message to all WebSocket clients.
The handleMessages() endpoint queries the CRDT store for all keys with the /messages/ prefix and returns them as a JSON array.
The handlePeers() endpoint asks the libp2p host for its currently connected peer IDs and returns them as a JSON array.
- Save internal/web/server.go.
- Verify the project compiles by running:
go build ./...
You should see no output and no errors. The command returns silently on success.
Seeing a compile error?
- Check that your import path matches your module name exactly. The import github.com/<user>/rescuenet/internal/node must use the same module path as your go.mod file.
- Make sure the query import is github.com/ipfs/go-datastore/query (a subpackage of go-datastore, not a separate module).
- If you see an unused import error, confirm you used both context and time in your handler methods. Both are needed.
help me fix my server.go compile error
✔️ Awesome, I've got everything!
Great. Double check you have saved internal/web/server.go and that go build ./... completes with no errors.
ⓧ I'd like to double check the full code
Here is the complete internal/web/server.go file:
package web
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
"github.com/<user>/rescuenet/internal/node"
"github.com/gorilla/websocket"
"github.com/ipfs/go-datastore"
"github.com/ipfs/go-datastore/query"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
type Message struct {
Key string `json:"key"`
Text string `json:"text"`
Timestamp string `json:"timestamp"`
}
type Server struct {
node *node.Node
clients map[*websocket.Conn]bool
broadcast chan []byte
mu sync.Mutex
}
func NewServer(n *node.Node) *Server {
s := &Server{
node: n,
clients: make(map[*websocket.Conn]bool),
broadcast: make(chan []byte, 64),
}
n.PutHook = func(key string, value []byte) {
s.broadcast <- value
}
return s
}
func (s *Server) Start() error {
go s.hubLoop()
http.Handle("/", http.FileServer(http.Dir("web")))
http.HandleFunc("/ws", s.handleWS)
http.HandleFunc("/api/sos", s.handleSOS)
http.HandleFunc("/api/messages", s.handleMessages)
http.HandleFunc("/api/peers", s.handlePeers)
fmt.Println("Web UI listening on http://0.0.0.0:8080")
return http.ListenAndServe("0.0.0.0:8080", nil)
}
func (s *Server) hubLoop() {
for msg := range s.broadcast {
s.mu.Lock()
for conn := range s.clients {
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
conn.Close()
delete(s.clients, conn)
}
}
s.mu.Unlock()
}
}
func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
s.mu.Lock()
s.clients[conn] = true
s.mu.Unlock()
defer func() {
s.mu.Lock()
delete(s.clients, conn)
s.mu.Unlock()
conn.Close()
}()
for {
if _, _, err := conn.ReadMessage(); err != nil {
break
}
}
}
func (s *Server) handleSOS(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Message string `json:"message"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
ts := time.Now().UTC().Format(time.RFC3339Nano)
msg := Message{Key: "/messages/" + ts, Text: req.Message, Timestamp: ts}
v, _ := json.Marshal(msg)
key := datastore.NewKey(msg.Key)
if err := s.node.CRDT.Put(context.Background(), key, v); err != nil {
http.Error(w, "store error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
}
func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) {
results, err := s.node.CRDT.Query(context.Background(), query.Query{Prefix: "/messages/"})
if err != nil {
http.Error(w, "query error", http.StatusInternalServerError)
return
}
defer results.Close()
var msgs []Message
for result := range results.Next() {
var m Message
if err := json.Unmarshal(result.Value, &m); err == nil {
msgs = append(msgs, m)
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(msgs)
}
func (s *Server) handlePeers(w http.ResponseWriter, r *http.Request) {
peers := s.node.Host.Network().Peers()
ids := make([]string, len(peers))
for i, p := range peers {
ids[i] = p.String()
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(ids)
}
Your web server is ready to serve a frontend, accept SOS messages, and push real-time updates to any browser on the network. Next up, you will build the actual HTML/CSS/JS frontend that connects to these endpoints.
Build the Frontend Interface
Your Go server is ready to serve files and push real-time updates over WebSocket. Now you need the actual web page that users will interact with.
In this step, you will create a single-file HTML frontend with a dark emergency theme, a pulsing SOS button, a live message feed, and JavaScript that connects to your server's WebSocket and REST endpoints.
In this step, get ready to:
- Build the HTML page structure with SOS button, message input, live feed, and peers panel.
- Style the interface with a high-contrast dark theme and red pulsing SOS animation.
- Wire up JavaScript for WebSocket real-time updates, API calls, and peer polling.
Create the HTML page with dark emergency styling
The server already maps GET / to serve files from the web/ directory. You need to create an index.html file there with the page structure and CSS styling.
- Create the file web/index.html by running:
touch web/index.html
- Open web/index.html in your editor and paste the following HTML structure and CSS:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RescueNet</title>
<style>
/* Dark emergency theme - high contrast with red accents */
body { background: #0d0d0d; color: #f0f0f0; font-family: monospace; margin: 0; padding: 1rem; }
h1 { color: #ff2222; letter-spacing: 2px; }
/* SOS button with pulsing red glow animation */
#sos-btn { background: #ff2222; color: #fff; border: none; padding: 1rem 2rem; font-size: 1.2rem; cursor: pointer; border-radius: 4px; animation: pulse 1.5s infinite; }
@keyframes pulse { 0%,100%{box-shadow:0 0 0 0 rgba(255,34,34,0.7)} 50%{box-shadow:0 0 0 12px rgba(255,34,34,0)} }
/* Message input field */
#msg-input { background: #1a1a1a; color: #f0f0f0; border: 1px solid #444; padding: 0.5rem; width: 60%; margin-right: 0.5rem; }
#send-btn { background: #444; color: #fff; border: none; padding: 0.5rem 1rem; cursor: pointer; }
/* Live message feed container */
#feed { margin-top: 1rem; max-height: 400px; overflow-y: auto; border: 1px solid #333; padding: 0.5rem; }
.msg { border-bottom: 1px solid #222; padding: 0.4rem 0; }
/* Peers panel - smaller muted text */
#peers { margin-top: 1rem; color: #aaa; font-size: 0.85rem; }
</style>
</head>
<body>
<!-- Page title with emergency icon -->
<h1>\ud83d\udea8 RescueNet</h1>
<!-- SOS alert button sends a critical default message -->
<button id="sos-btn" onclick="sendSOS()">SEND SOS ALERT</button>
<!-- Custom message input area -->
<div style="margin-top:1rem">
<input id="msg-input" type="text" placeholder="Type a message..." />
<button id="send-btn" onclick="sendMsg()">Send</button>
</div>
<!-- Live feed of incoming messages -->
<div id="feed"></div>
<!-- Connected peers counter -->
<div id="peers">Connected peers: loading...</div>
<script>
</script>
</body>
</html>
What does this HTML do?
- The <style> block defines a dark background (#0d0d0d) with light text for maximum readability in emergency conditions.
- The @keyframes pulse animation creates a red glow that expands and fades around the SOS button, drawing immediate attention.
- The #feed div will hold incoming messages. It has a max height with scroll overflow so it stays usable even with hundreds of messages.
- The #peers div shows how many P2P nodes are connected to this one.
- Save web/index.html.
You cannot verify this visually yet because the Go binary needs to be compiled and run. The JavaScript in the next chunk will make the page functional.
Wire up JavaScript for real-time communication
The page structure is in place. Now you need JavaScript to connect to the WebSocket endpoint, call the REST API, and render messages as they arrive from the P2P mesh.
- In web/index.html, find the empty <script> tag near the bottom of the file:
<script>
</script>
- Replace that empty <script> block with the following JavaScript:
<script>
// Open a WebSocket connection to the server for real-time message push
const ws = new WebSocket(`ws://${location.host}/ws`);
// When a message arrives over WebSocket, parse and display it
ws.onmessage = e => appendMsg(JSON.parse(e.data));
// Load all existing messages from the CRDT store on page load
fetch('/api/messages').then(r => r.json()).then(msgs => {
if (msgs) msgs.forEach(appendMsg);
});
// Append a message object to the live feed
function appendMsg(m) {
const feed = document.getElementById('feed');
const div = document.createElement('div');
div.className = 'msg';
div.textContent = `[${m.timestamp}] ${m.text}`;
feed.prepend(div);
}
// Send the default SOS critical alert message
function sendSOS() {
postMsg('\ud83d\udea8 SOS ALERT - Immediate assistance required!');
}
// Send whatever the user typed in the input field
function sendMsg() {
const input = document.getElementById('msg-input');
if (input.value.trim()) { postMsg(input.value.trim()); input.value = ''; }
}
// POST a message to the /api/sos endpoint which writes to the CRDT store
function postMsg(text) {
fetch('/api/sos', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({message: text}) });
}
// Poll for connected peers every 5 seconds and update the peers panel
setInterval(() => {
fetch('/api/peers').then(r => r.json()).then(peers => {
document.getElementById('peers').textContent = `Connected peers (${(peers||[]).length}): ${(peers||[]).join(', ') || 'none'}`;
});
}, 5000);
</script>
What does this JavaScript do?
- new WebSocket(...) opens a persistent connection to /ws. When any node in the mesh writes a message, the server's hubLoop pushes it through this socket.
- fetch('/api/messages') on page load populates the feed with all existing messages already stored in the CRDT datastore.
- sendSOS() fires a pre-written critical alert. sendMsg() sends whatever custom text the user typed.
- The setInterval polls /api/peers every 5 seconds so the user can see how many mesh nodes are connected.
- Save web/index.html.
- Confirm the file exists by running:
ls web/
You should see index.html in the output.
Don't see index.html?
- Make sure you are in the project root directory (rescuenet), not inside a subdirectory. Run ls and confirm you see go.mod, internal/, and web/ at the same level.
- If the web/ directory does not exist, you may have missed it in Step 1. Create it with mkdir web then create the file again.
- help me find why my web/index.html file is missing
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RescueNet</title>
<style>
body { background: #0d0d0d; color: #f0f0f0; font-family: monospace; margin: 0; padding: 1rem; }
h1 { color: #ff2222; letter-spacing: 2px; }
#sos-btn { background: #ff2222; color: #fff; border: none; padding: 1rem 2rem; font-size: 1.2rem; cursor: pointer; border-radius: 4px; animation: pulse 1.5s infinite; }
@keyframes pulse { 0%,100%{box-shadow:0 0 0 0 rgba(255,34,34,0.7)} 50%{box-shadow:0 0 0 12px rgba(255,34,34,0)} }
#msg-input { background: #1a1a1a; color: #f0f0f0; border: 1px solid #444; padding: 0.5rem; width: 60%; margin-right: 0.5rem; }
#send-btn { background: #444; color: #fff; border: none; padding: 0.5rem 1rem; cursor: pointer; }
#feed { margin-top: 1rem; max-height: 400px; overflow-y: auto; border: 1px solid #333; padding: 0.5rem; }
.msg { border-bottom: 1px solid #222; padding: 0.4rem 0; }
#peers { margin-top: 1rem; color: #aaa; font-size: 0.85rem; }
</style>
</head>
<body>
<h1>\ud83d\udea8 RescueNet</h1>
<button id="sos-btn" onclick="sendSOS()">SEND SOS ALERT</button>
<div style="margin-top:1rem">
<input id="msg-input" type="text" placeholder="Type a message..." />
<button id="send-btn" onclick="sendMsg()">Send</button>
</div>
<div id="feed"></div>
<div id="peers">Connected peers: loading...</div>
<script>
const ws = new WebSocket(`ws://${location.host}/ws`);
ws.onmessage = e => appendMsg(JSON.parse(e.data));
fetch('/api/messages').then(r => r.json()).then(msgs => {
if (msgs) msgs.forEach(appendMsg);
});
function appendMsg(m) {
const feed = document.getElementById('feed');
const div = document.createElement('div');
div.className = 'msg';
div.textContent = `[${m.timestamp}] ${m.text}`;
feed.prepend(div);
}
function sendSOS() {
postMsg('\ud83d\udea8 SOS ALERT - Immediate assistance required!');
}
function sendMsg() {
const input = document.getElementById('msg-input');
if (input.value.trim()) { postMsg(input.value.trim()); input.value = ''; }
}
function postMsg(text) {
fetch('/api/sos', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({message: text}) });
}
setInterval(() => {
fetch('/api/peers').then(r => r.json()).then(peers => {
document.getElementById('peers').textContent = `Connected peers (${(peers||[]).length}): ${(peers||[]).join(', ') || 'none'}`;
});
}, 5000);
</script>
</body>
</html>
Your frontend is complete. In the next step, you will compile the full binary and test the mesh network with multiple nodes communicating in real-time.
Wire Up the Main Entry Point
You have all the building blocks in place. The P2P node discovers peers, the CRDT store replicates data, the web server pushes updates over WebSocket, and the frontend renders messages in real-time.
Now you need a single entry point that boots everything in the right order and keeps the process alive until you press Ctrl+C. That entry point is cmd/rescuenet/main.go.
In this step, get ready to:
- Create the main entry point that orchestrates the node, store, and web server.
- Compile the full application into a single binary.
- Run the binary and verify it starts the P2P node and web UI.
Create the main.go file
Go programs start execution at the main() function inside a package main file. This function will wire together your node, datastore, and web server in a controlled boot sequence.
- Create a new file at cmd/rescuenet/main.go and add the following code:
package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"github.com/[[GITHUB_USER="your GitHub username"]]/rescuenet/internal/node"
"github.com/[[GITHUB_USER="your GitHub username"]]/rescuenet/internal/web"
)
func main() {
// Create a context that cancels on SIGINT or SIGTERM
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
// Boot the libp2p host with GossipSub and mDNS discovery
n, err := node.NewNode(ctx)
if err != nil {
log.Fatalf("create node: %v", err)
}
// Log the node's peer ID and listening addresses
fmt.Printf("Peer ID: %s\n", n.Host.ID())
for _, addr := range n.Host.Addrs() {
fmt.Printf("Listening: %s/p2p/%s\n", addr, n.Host.ID())
}
// Initialize the Merkle-CRDT datastore
if err := n.InitStore(ctx); err != nil {
log.Fatalf("init store: %v", err)
}
// Start the web server in a background goroutine
srv := web.NewServer(n)
go func() {
if err := srv.Start(); err != nil {
log.Printf("web server: %v", err)
}
}()
// Block until the context is cancelled (Ctrl+C)
<-ctx.Done()
fmt.Println("Shutting down...")
}
What does this code do?
signal.NotifyContext creates a context that automatically cancels when the process receives an interrupt signal (Ctrl+C) or a SIGTERM. This gives all goroutines a clean shutdown signal.
The boot sequence matters. The node must exist before the store can initialize (the store needs the libp2p host for DAG exchange). The web server must come last because it depends on both the node and the store.
The web server runs in a goroutine because srv.Start() blocks on http.ListenAndServe. Without the goroutine, the program would never reach the <-ctx.Done() line.
- Save cmd/rescuenet/main.go.
Import path errors?
Make sure the import paths match your module name in go.mod exactly. If you initialized your module as github.com/yourname/rescuenet, the imports must use that same prefix.
help me fix import path errors in my Go project
Build and run the binary
Go compiles your entire project into a single static binary. This binary is all you need to run a RescueNet node on any machine.
- Compile the application by running this command from your project root:
go build ./cmd/rescuenet/
- Confirm the binary was created by running:
ls rescuenet
You should see rescuenet (or rescuenet.exe on Windows) listed in the output.
- Start your RescueNet node by running:
./rescuenet
You should see output showing your node's Peer ID and listening addresses, followed by the web server message. It looks something like this:
Peer ID: 12D3KooW...
Listening: /ip4/192.168.1.100/tcp/54321/p2p/12D3KooW...
Listening: /ip4/127.0.0.1/tcp/54321/p2p/12D3KooW...
Web UI listening on http://0.0.0.0:8080
- Open your browser and navigate to http://localhost:8080.
You should see the RescueNet web UI with the red SOS button, message input field, and connected peers panel.
- Press Ctrl+C in your terminal to stop the node. You should see Shutting down... printed before the process exits.
Build fails with missing dependencies?
If you see errors about missing packages, run go mod tidy to download any missing transitive dependencies. Then try the build command again.
If you see an error about port 8080 already being in use, another process is occupying that port. Stop it or check if you have another instance of RescueNet still running.
help me debug build errors in my Go RescueNet project
✔️ Awesome, I've got everything!
Great. Make sure you have saved cmd/rescuenet/main.go and that your binary compiled successfully.
ⓧ I'd like to double check the full code
Here is the complete cmd/rescuenet/main.go file:
package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"github.com/<user>/rescuenet/internal/node"
"github.com/<user>/rescuenet/internal/web"
)
func main() {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
n, err := node.NewNode(ctx)
if err != nil {
log.Fatalf("create node: %v", err)
}
fmt.Printf("Peer ID: %s\n", n.Host.ID())
for _, addr := range n.Host.Addrs() {
fmt.Printf("Listening: %s/p2p/%s\n", addr, n.Host.ID())
}
if err := n.InitStore(ctx); err != nil {
log.Fatalf("init store: %v", err)
}
srv := web.NewServer(n)
go func() {
if err := srv.Start(); err != nil {
log.Printf("web server: %v", err)
}
}()
<-ctx.Done()
fmt.Println("Shutting down...")
}
Your RescueNet node compiles and runs as a single binary. Next up, you will test multi-node communication by running several instances and watching messages replicate across the mesh.
Test the Mesh Network Locally
You have a complete P2P node with a web UI, CRDT replication, and mDNS discovery. The code compiles, the server starts, but you have not yet proven the system actually works as a mesh.
In this step, you will build the binary, run multiple nodes on your machine, verify they discover each other and sync messages, and then prove that the Merkle-CRDT handles network partitions gracefully.
In this step, get ready to:
- Build and run your first RescueNet node.
- Verify peer discovery and real-time message sync between nodes.
- Demonstrate partition tolerance by killing and restarting a node.
Build and run the first node
Before testing the mesh, you need a compiled binary. The Go compiler will pull everything together into a single executable.
- Open a terminal window. Make sure you are inside your rescuenet project folder (the one containing go.mod).
- Build the binary by running this command:
go build ./cmd/rescuenet/
- Start the first node by running:
./rescuenet
Your terminal prints the node's peer ID and its listening addresses. It also prints that the web UI is listening on http://0.0.0.0:8080.
What should I see?
You should see output like this (your peer ID and port numbers will differ):
Peer ID: 12D3KooW...
Listening: /ip4/192.168.1.42/tcp/54321/p2p/12D3KooW...
Listening: /ip4/127.0.0.1/tcp/54321/p2p/12D3KooW...
Web UI listening on http://0.0.0.0:8080
The Peer ID is your node's unique identity on the mesh. The Listening lines show the TCP addresses other nodes can use to connect.
- Open your browser and navigate to http://localhost:8080.
- You should see the RescueNet UI with the red SOS button, a message input field, and a peers panel showing 0 connected peers.
Don't see the web UI?
- Make sure you ran the binary from inside your rescuenet project directory. The server looks for the web/ folder relative to where you run the command.
- Check that port 8080 is not already in use by another application.
- If the build step failed, check for any missing dependencies with go mod tidy and rebuild.
help me debug why the RescueNet web UI is not loading at localhost:8080
Run a second node and verify message sync
A single node is just a server. The magic happens when a second node joins the mesh and they discover each other automatically via mDNS.
Each node uses a relative path (.rescuenet-data/pebble) for its datastore. Running the second instance from a different directory gives it its own separate datastore.
- Open a second terminal window. Keep the first node running.
- Create a temporary directory for the second node and run the binary from there:
mkdir -p /tmp/rescuenet-node2
cp ./rescuenet /tmp/rescuenet-node2/
cp -r ./web /tmp/rescuenet-node2/
cd /tmp/rescuenet-node2
./rescuenet
What about the port conflict?
The second node will log an error about port 8080 being in use. That is expected. The libp2p P2P layer uses a random TCP port and works independently of the web server.
You will interact with both nodes through the first node's UI at http://localhost:8080. The second node participates in the mesh but does not need its own UI for this test.
- In the second terminal, you should see a new peer ID printed. Within a few seconds, mDNS discovery connects both nodes automatically.
- Switch back to your browser at http://localhost:8080. Wait up to 5 seconds for the peers panel to update.
- You should see Connected peers (1) followed by the second node's peer ID.
- Verify peer discovery from your terminal by running this in a third terminal window:
curl http://localhost:8080/api/peers
You should see a JSON array containing the second node's peer ID string.
- Now test message sync. In your browser, type a message in the input field and click Send.
- Verify the message was written to the CRDT store and replicated to the second node by running:
curl http://localhost:8080/api/messages
The response shows your message in JSON format. This message now exists in both nodes' CRDT datastores because the Merkle-CRDT replicated it via GossipSub.
Test with a second device for the full experience
For the complete two-UI experience, find the IP address shown in your first node's Listening output (e.g. 192.168.1.42). From another laptop or phone on the same WiFi, open http://<that-ip>:8080 in a browser.
Run the binary on the second machine and both UIs update independently as messages flow through the mesh.
Peers not discovering each other?
- mDNS uses multicast on the local network. If you are on a corporate or university network, multicast may be blocked. Try a personal WiFi hotspot instead.
- On macOS, check that your firewall is not blocking incoming connections for the rescuenet binary. You may see a system prompt asking to allow it.
- Make sure both nodes are on the same network subnet. VPN connections can route traffic to a different interface.
help me debug why my two RescueNet nodes are not discovering each other via mDNS
Demonstrate partition tolerance
The real power of a Merkle-CRDT is what happens when nodes disconnect and reconnect. Messages written while a node is offline are automatically merged when it rejoins the mesh. No conflicts, no data loss.
- In your second terminal (where node 2 is running), press Ctrl+C to kill the second node.
- Check the peers panel in your browser. Within a few seconds it should show Connected peers (0).
- While the second node is offline, send 2-3 new messages from the browser UI on the first node.
- Verify the messages exist on node 1 by checking the feed in your browser or running:
curl http://localhost:8080/api/messages
- Now restart the second node. In the second terminal, run:
cd /tmp/rescuenet-node2
./rescuenet
Watch the first node's browser UI. Within seconds, the peers panel shows the second node reconnected. The Merkle-CRDT automatically exchanges DAG heads and merges any state that diverged while the nodes were apart.
How does CRDT merge work?
When node 2 reconnects, the PubSubBroadcaster on each node announces its latest Merkle DAG head CID. Each node compares heads and fetches any DAG blocks it is missing via the IPFS-Lite DAG exchange.
Because go-ds-crdt uses an Add-Wins Observed-Remove Set, all messages from both nodes merge cleanly. No coordination protocol needed. No leader election.
- To fully prove convergence, kill the first node (Ctrl+C in terminal 1) and restart it:
./rescuenet
- Open http://localhost:8080 again in your browser. All messages from both sessions appear in the feed, proving the CRDT datastore persisted and merged everything correctly.
Messages missing after restart?
- Make sure you ran the restarted node from the same directory as before. The Pebble datastore lives at .rescuenet-data/pebble relative to the working directory.
- If you accidentally deleted the .rescuenet-data/ folder, the node starts fresh with no history. The CRDT will still receive new messages from connected peers, but old messages sent only on this node are lost.
help me understand why my restarted node does not show previous messages
Your mesh network works. Nodes discover each other, messages replicate in real-time, and the CRDT handles partitions without breaking a sweat. Next up, you will containerize the application with Docker for easy deployment and testing.
Dockerize the Application
Your mesh network runs perfectly from a compiled Go binary. Deploying it to other machines means installing Go, cloning the repo, and building from source on each one.
Docker solves this by packaging the binary, the web assets, and the runtime into a single portable image. In this step, you will write a multi-stage Dockerfile and a Compose file so any machine can launch a RescueNet node with one command.
In this step, get ready to:
- Write a multi-stage Dockerfile that compiles and packages RescueNet.
- Create a docker-compose.yml for one-command deployment with host networking.
- Build the image and verify the containerized node serves the web UI.
Before writing the Dockerfile, confirm Docker is available on your machine.
- Check your Docker installation by running:
docker --version
✔️ I see a version number
Docker is ready. Move on to writing the Dockerfile below.
ⓧ Command not found
Docker is not installed yet. Download and install Docker Desktop from the official site:
- Docker Desktop for macOS / Windows / Linux
- After installation, open Docker Desktop and wait for the engine to start (the whale icon in your system tray shows a green dot when ready).
- Run docker --version again to confirm it works.
Write the multi-stage Dockerfile
A multi-stage build keeps the final image small. The first stage pulls in the full Go toolchain to compile the binary. The second stage copies only the compiled binary and the web/ directory into a minimal Alpine image.
- Create a file called Dockerfile in the root of your rescuenet project folder (the same level as go.mod).
- Paste the following into Dockerfile:
# Stage 1: Build
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /rescuenet ./cmd/rescuenet/
# Stage 2: Run
FROM alpine:latest
WORKDIR /app
COPY --from=builder /rescuenet /app/rescuenet
COPY web/ /app/web/
EXPOSE 8080
ENTRYPOINT ["/app/rescuenet"]
What does this Dockerfile do?
- Stage 1 (builder) uses golang:1.22-alpine to download dependencies and compile the binary. The COPY go.mod go.sum then RUN go mod download pattern caches dependencies so rebuilds are fast.
- Stage 2 starts fresh from alpine:latest (a ~5 MB base image). It copies the compiled binary and the web assets. No Go toolchain in the final image.
- ENTRYPOINT runs the binary automatically when the container starts.
- Save Dockerfile.
Create the docker-compose.yml
mDNS peer discovery uses multicast packets on the local network. For a container to send and receive these packets, it needs direct access to the host's network interfaces. The network_mode: host setting gives the container this access.
- Create a file called docker-compose.yml in the root of your rescuenet project folder (same level as Dockerfile).
- Paste the following into docker-compose.yml:
version: '3.8'
services:
rescuenet:
build: .
network_mode: host
volumes:
- rescuenet-data:/app/.rescuenet-data
restart: unless-stopped
volumes:
rescuenet-data:
What does this Compose file do?
- build: . tells Docker to build the image from the Dockerfile in the current directory.
- network_mode: host removes network isolation so the container shares the host's network stack. This lets mDNS multicast packets flow freely between containers and bare-metal peers.
- The named volume rescuenet-data persists the Pebble datastore across container restarts so CRDT state survives.
- restart: unless-stopped keeps the node running if it crashes or the host reboots.
Why host networking instead of bridge?
Docker's default bridge network isolates container traffic behind NAT. mDNS discovery relies on multicast packets reaching the local subnet. With bridge networking, those packets never leave the Docker network and peers on other machines can't find each other. Host networking solves this by giving the container direct access to the physical network interface.
- Save docker-compose.yml.
Build and run the container
Time to verify that Docker can compile your Go binary and launch a working RescueNet node from the image.
- Stop any RescueNet nodes you have running in other terminals (press Ctrl+C in each).
- Build the Docker image by running:
docker build -t rescuenet .
What does this command do?
docker build reads the Dockerfile, executes both stages, and produces a tagged image called rescuenet. The first build downloads all Go dependencies, so it may take a few minutes. Subsequent builds use the cached layer.
- You should see output ending with a line like Successfully tagged rescuenet:latest or a build ID confirmation.
- Launch the node using Docker Compose by running:
docker compose up
- You should see the familiar startup output in your terminal: the Peer ID, listening addresses, and the Web UI listening on http://0.0.0.0:8080 message.
- Open http://localhost:8080 in your browser. The RescueNet web UI loads from the containerized node.
Scale across machines
On separate machines connected to the same WiFi, clone your repo and run docker compose up on each. With host networking, mDNS lets the containers discover each other just like bare-metal nodes. You can also use docker compose up --scale rescuenet=3 on a single host for testing, though you will need to adjust the web port to avoid conflicts.
Build failing or container won't start?
- If you see a go.sum error during build, make sure you ran go mod tidy locally before building. The Dockerfile copies go.sum and expects it to be up to date.
- If the container starts but you cannot reach port 8080, confirm Docker Desktop is running and that no other process is using port 8080.
- On Linux, network_mode: host works natively. On macOS and Windows, Docker Desktop runs containers inside a Linux VM, so host networking refers to the VM's network. mDNS discovery between the container and other machines may not work on Docker Desktop. For full cross-machine mDNS, run the binary directly or use a Linux host.
Help me debug my Docker build or container startup issue
- Press Ctrl+C in the terminal to stop the container when you are done testing.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files.
ⓧ I'd like to double check the full code
# Stage 1: Build
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /rescuenet ./cmd/rescuenet/
# Stage 2: Run
FROM alpine:latest
WORKDIR /app
COPY --from=builder /rescuenet /app/rescuenet
COPY web/ /app/web/
EXPOSE 8080
ENTRYPOINT ["/app/rescuenet"]
version: '3.8'
services:
rescuenet:
build: .
network_mode: host
volumes:
- rescuenet-data:/app/.rescuenet-data
restart: unless-stopped
volumes:
rescuenet-data:
Your application is now containerized and deployable with a single command. Next up, you will write a README and push everything to GitHub so others can clone and run your mesh network.
Write the README and Push to GitHub
Your RescueNet application is Dockerized and ready for deployment on any machine with a single command. Now it is time to document what you built and share it with the world.
A strong README transforms a folder of code into a professional portfolio project. It tells other developers what the project does, how to run it, and why the architecture works the way it does.
In this step, get ready to:
- Write a comprehensive README.md with architecture diagrams, quick start instructions, and tech stack.
- Make a final git commit and push to GitHub.
- Verify the README renders correctly on GitHub.
Write the README
A good README for a systems project like RescueNet should cover: what it is, how the architecture fits together, how to run it, and what technologies power it. You will include a text-based architecture diagram so readers can understand the node topology at a glance.
- Create a file called README.md in the root of your rescuenet project folder, then paste the following content:
# 🚨 RescueNet
A fully decentralized peer-to-peer disaster communication mesh network built in Go.
## Architecture
```
[Node A: Browser UI] <--WebSocket--> [Go Backend A]
|
GossipSub / mDNS
|
[Node B: Browser UI] <--WebSocket--> [Go Backend B]
|
Merkle-CRDT Sync
|
[Node C: Browser UI] <--WebSocket--> [Go Backend C]
```
## Features
- Automatic peer discovery via mDNS (no configuration needed)
- GossipSub mesh for efficient P2P message routing
- Merkle-CRDT datastore for conflict-free offline sync
- Real-time browser UI with WebSocket push updates
- Works on local WiFi with no internet access
- Docker support for easy deployment
## Quick Start
### Local
```bash
git clone https://github.com/<user>/rescuenet
cd rescuenet
go build ./cmd/rescuenet/
./rescuenet
# Open http://localhost:8080
```
### Docker
```bash
docker compose up
# Open http://localhost:8080
```
## How It Works
**P2P Layer**: Each node creates a libp2p host and uses mDNS to discover peers on the local network. Once discovered, nodes connect and form a GossipSub mesh for efficient pub/sub message routing.
**CRDT Sync**: All SOS messages are stored in a go-ds-crdt Merkle-CRDT datastore backed by Pebble. When nodes reconnect after a partition, the Merkle DAG is exchanged via IPFS-Lite and all state converges automatically without conflicts.
**Web UI**: Each node serves a web UI on port 8080. A WebSocket connection pushes new messages to the browser in real-time as they arrive from the mesh.
## Tech Stack
| Component | Technology |
|-----------|------------|
| P2P Networking | go-libp2p |
| Pub/Sub | GossipSub |
| Peer Discovery | mDNS |
| CRDT Store | go-ds-crdt |
| DAG Sync | IPFS-Lite |
| Local Storage | Pebble |
| WebSocket | gorilla/websocket |
| Container | Docker |
What does this README cover?
- The Architecture section uses a text-based diagram to show how multiple nodes connect via GossipSub and sync state via Merkle-CRDTs.
- The Quick Start section gives readers two paths: build from source locally, or use Docker for a one-command launch.
- The How It Works section explains the three key layers (P2P, CRDT, Web UI) so readers understand why the system converges without conflicts.
- The Tech Stack table gives a scannable reference for all major dependencies.
- Save README.md.
Replace the placeholder username
The Quick Start section references <user> in the clone URL. You will replace this with your actual GitHub username after you create the repository in the next substep.
Markdown not rendering in your editor?
Some editors show raw markdown without a preview. In VS Code, press Cmd+Shift+V (macOS) or Ctrl+Shift+V (Windows) to open a Markdown preview pane.
help me preview my README in VS Code
Commit and push to GitHub
With the README in place, your project is complete and ready for its final commit. You will create a GitHub repository and push the full codebase.
- Stage all files and make the final commit by running these commands:
git add -A
git commit -m "feat: complete RescueNet P2P disaster mesh network"
What does this commit do?
The -A flag stages all new, modified, and deleted files. This single commit captures the README alongside any final tweaks, marking the project as complete.
- Open your browser and go to https://github.com/new to create a new repository.
- Enter rescuenet in the Repository name field.
- Leave the repository set to Public.
- Do NOT check any of the initialization checkboxes (README, .gitignore, or license). You already have these files locally.
- Click Create repository.
Why skip the initialization options?
GitHub offers to create a README and .gitignore for you, but you already have both locally. Initializing them on GitHub would create a conflict with your local commit history.
- Back in your terminal, connect your local repository to GitHub and push by running these commands:
git remote add origin [[REPO_URL="https://github.com/your-username/rescuenet.git"]]
git push -u origin main
What do these commands do?
- git remote add origin tells Git where to push your code. Replace the URL with the one shown on your new GitHub repository page.
- git push -u origin main uploads all your commits to GitHub and sets main as the default branch for future pushes.
Push rejected or authentication failed?
If you see a permission error, GitHub likely needs authentication. Make sure you have either SSH keys configured or a personal access token set up for HTTPS.
If the push is rejected because the remote contains work you don't have locally, you may have accidentally initialized the repo with a README on GitHub. Delete the GitHub repo and recreate it without any initialization.
help me fix my git push authentication error
Verify the README renders on GitHub
GitHub automatically renders README.md files on the repository's main page. This is the first thing visitors see when they find your project.
- Open your repository page on GitHub (the URL will be https://github.com/ followed by your username and /rescuenet).
- Scroll down below the file list. You should see the rendered README with the architecture diagram, features list, quick start sections, and tech stack table.
- Confirm the architecture diagram displays as a monospace code block showing three nodes connected by GossipSub and Merkle-CRDT sync.
- Confirm the Tech Stack table renders with proper column alignment.
README not showing or formatting is broken?
Make sure the file is named exactly README.md (case-sensitive) and sits in the root of your repository.
If the architecture diagram looks misaligned, check that the code fence (triple backticks) opens and closes properly around the diagram text.
help me fix my README formatting on GitHub
- Update the <user> placeholder in the Quick Start clone URL to your actual GitHub username, then commit and push the fix:
git add README.md
git commit -m "docs: update clone URL with username"
git push
✔️ Awesome, I've got everything!
Great. Double check you have saved all files and your GitHub repository shows the full README.
ⓧ I'd like to double check the full code
# 🚨 RescueNet
A fully decentralized peer-to-peer disaster communication mesh network built in Go.
## Architecture
```
[Node A: Browser UI] <--WebSocket--> [Go Backend A]
|
GossipSub / mDNS
|
[Node B: Browser UI] <--WebSocket--> [Go Backend B]
|
Merkle-CRDT Sync
|
[Node C: Browser UI] <--WebSocket--> [Go Backend C]
```
## Features
- Automatic peer discovery via mDNS (no configuration needed)
- GossipSub mesh for efficient P2P message routing
- Merkle-CRDT datastore for conflict-free offline sync
- Real-time browser UI with WebSocket push updates
- Works on local WiFi with no internet access
- Docker support for easy deployment
## Quick Start
### Local
```bash
git clone https://github.com/<user>/rescuenet
cd rescuenet
go build ./cmd/rescuenet/
./rescuenet
# Open http://localhost:8080
```
### Docker
```bash
docker compose up
# Open http://localhost:8080
```
## How It Works
**P2P Layer**: Each node creates a libp2p host and uses mDNS to discover peers on the local network. Once discovered, nodes connect and form a GossipSub mesh for efficient pub/sub message routing.
**CRDT Sync**: All SOS messages are stored in a go-ds-crdt Merkle-CRDT datastore backed by Pebble. When nodes reconnect after a partition, the Merkle DAG is exchanged via IPFS-Lite and all state converges automatically without conflicts.
**Web UI**: Each node serves a web UI on port 8080. A WebSocket connection pushes new messages to the browser in real-time as they arrive from the mesh.
## Tech Stack
| Component | Technology |
|-----------|------------|
| P2P Networking | go-libp2p |
| Pub/Sub | GossipSub |
| Peer Discovery | mDNS |
| CRDT Store | go-ds-crdt |
| DAG Sync | IPFS-Lite |
| Local Storage | Pebble |
| WebSocket | gorilla/websocket |
| Container | Docker |
Secret mission
SOS Priority Classifier
Not all distress calls are equal. In a real disaster, a message about someone trapped under rubble demands faster attention than a request for drinking water. Add a pure-Go keyword classifier that scans every message for urgency indicators and tags it with a priority level that replicates across all peers via the CRDT layer.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally and uses no paid cloud services, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The rescuenet project directory on your Desktop (Go source code, web assets, compiled binary).
- One or more .rescuenet-data/ directories created during testing (Pebble datastore files).
- Docker containers and images created during the Dockerize step.
- The GitHub repository you created for this project.
Keep everything running
No action needed. Choose this if you are still actively building or planning to demo your mesh network to others.
- Your Docker containers will keep running until you stop them or restart your machine.
- Your local project files and data directories remain on disk.
- Your GitHub repository stays public (or private, depending on how you created it).
Pause - I'll come back to this later
Shut down running processes to free up memory and CPU, but keep all files so you can resume later.
- Stop any running RescueNet processes by pressing Ctrl+C in each terminal window.
- Stop Docker containers by navigating to your project folder and running:
docker compose down
- Your project files, data directories, and Docker images stay on disk for next time.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is permanent.
Stop and remove Docker containers, networks, and images by running these commands from your project directory:
docker compose down --rmi all -v
Delete the project directory and any data directories. On macOS/Linux:
cd ~/Desktop
rm -rf rescuenet
On Windows:
cd ~\Desktop
rmdir /s /q rescuenet
What about .rescuenet-data directories?
If you ran multiple node instances during testing, each one created its own data directory. These are inside your project folder, so deleting the project directory removes them too.
Delete the GitHub repository:
- Open your repository on GitHub and click Settings.
- Scroll down to the Danger Zone section.
- Click Delete this repository and confirm by typing the repository name.
That's a Wrap!
That's a Wrap!
Nice work! You just built RescueNet, a fully decentralized disaster mesh network with a web UI, Merkle-CRDT sync, and Docker deployment.
You've learned how to:
- Build a go-libp2p peer node with automatic mDNS discovery and GossipSub mesh networking for decentralized communication without any central server.
- Implement Merkle-CRDT state replication using go-ds-crdt that mathematically guarantees conflict-free convergence, even after network partitions.
- Create a real-time web UI with WebSocket push updates accessible from any device on the local network, and Dockerize the entire system for one-command deployment.
- Built a keyword-based SOS priority classifier in pure Go that flags critical messages and displays color-coded priority badges in the web UI.
Ready to quiz yourself?