Skip to content

Run a companion node

This page shows how to run your own service as a separate libp2p peer beside a Ricochet server. You’ll build a small go-p2p-forge server with one request/response protocol and one GossipSub announcement, call it from a client, and let Ricochet carry its topic.

A companion node needs no change to go-ricochet. It is a second process with its own peer ID, so it works with the official release and survives every upgrade.

The samples on this page build against go-p2p-forge v0.2.1, go-libp2p-udx-transport v0.1.8 and go-libp2p v0.49.0, the versions go-ricochet 1.1.0 uses. They were compiled and run against each other.

Terminal window
go mod init example.com/companion
go get github.com/stephanfeb/go-p2p-forge@v0.2.1 \
github.com/stephanfeb/go-libp2p-udx-transport@v0.1.8 \
github.com/libp2p/go-libp2p@v0.49.0
main.go
package main
import (
"context"
"encoding/json"
"flag"
"log/slog"
"os"
"os/signal"
"strings"
"time"
"github.com/libp2p/go-libp2p"
udxtransport "github.com/stephanfeb/go-libp2p-udx-transport"
forge "github.com/stephanfeb/go-p2p-forge"
"github.com/stephanfeb/go-p2p-forge/codec"
"github.com/stephanfeb/go-p2p-forge/middleware"
"github.com/stephanfeb/go-p2p-forge/service"
)
const (
HelloProtocol = "/example/hello/1.0.0"
AnnounceTopic = "/example/announce"
)
type HelloRequest struct {
Name string `json:"name"`
}
type HelloResponse struct {
Greeting string `json:"greeting"`
}
func main() {
port := flag.Int("port", 55224, "UDX listen port")
bootstrap := flag.String("bootstrap", "", "comma-separated multiaddrs, e.g. your Ricochet server's")
flag.Parse()
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
pool := codec.NewBufferPool()
cfg := forge.DefaultConfig()
cfg.Host.Port = *port
cfg.DataDirectory = "./companion-data" // peer_identity.key is created here
if *bootstrap != "" {
peers := strings.Split(*bootstrap, ",")
cfg.Host.BootstrapPeers = peers
cfg.Node.BootstrapPeers = peers
}
srv := forge.NewServer(
forge.WithConfig(cfg),
forge.WithLogger(logger),
forge.WithTransport(libp2p.Transport(udxtransport.NewTransport)),
)
// A request/response protocol: one JSON frame in, one JSON frame out.
hello := forge.NewPipeline(logger,
middleware.Recovery(),
forge.JSONResponseWriter(),
forge.FrameDecodeMiddleware(pool),
forge.JSONDeserialize[HelloRequest](),
func(sc *forge.StreamContext, next func()) {
req := sc.Request.(*HelloRequest)
sc.Response = &HelloResponse{Greeting: "hello, " + req.Name}
},
).WithActiveStreams(srv.ActiveStreams())
srv.Handle(HelloProtocol, hello.StreamHandler())
// A background service: announce this node on a GossipSub topic.
srv.AddService(service.NewTickerService("announce", 30*time.Second,
func(ctx context.Context) error {
n := srv.Node()
if err := n.JoinTopic(AnnounceTopic); err != nil {
return err
}
msg, _ := json.Marshal(map[string]string{
"peerId": srv.PeerID().String(),
"protocol": HelloProtocol,
})
return n.Publish(ctx, AnnounceTopic, msg)
}, logger))
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
if err := srv.ListenAndServe(ctx); err != nil {
logger.Error("server error", "error", err)
os.Exit(1)
}
}

Unlike go-ricochet, a companion registers its handlers with srv.Handle before it starts, and forge installs them in Start. Its background work is a forge TickerService, which runs once at start and then on every tick, and stops with the server.

Two details matter:

  • Keep the UDX transport. When cfg.Host.Port is set and there are no ListenAddresses, forge listens on /ip4/0.0.0.0/udp/<port>/udx. Without forge.WithTransport(libp2p.Transport(udxtransport.NewTransport)) the host can’t parse that address and Start fails with unknown protocol udx. Passing a transport also turns off libp2p’s default transports, so the node speaks UDX only, like Ricochet.
  • Use a different port from Ricochet. Ricochet’s default is 55223. Open the companion’s UDP port in the firewall too.

Forge also has rate limiters, connection callbacks, server-initiated streams, custom codecs and a health check service. See forge’s user guide.

Terminal window
go run . -port 55224

On first start forge creates ./companion-data/peer_identity.key, and the node keeps that peer ID across restarts. Back up the file: it is the companion’s identity. The server started log line gives the peer ID and the listen addresses:

level=INFO msg="server started" peer_id=12D3KooW... addrs="[/ip4/127.0.0.1/udp/55224/udx ...]"

Clients reach the companion by its own peer ID, not Ricochet’s. Give them its full multiaddr, /ip4/<public IP>/udp/55224/udx/p2p/<peer ID>, the same way you give them Ricochet’s: in app config, a document they read from Ricochet, or the announcement on the topic the companion publishes.

A client needs only a UDX libp2p host and forge’s frame codec, a 4-byte big-endian length followed by the payload:

client/main.go
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"time"
"github.com/libp2p/go-libp2p"
"github.com/libp2p/go-libp2p/core/peer"
udxtransport "github.com/stephanfeb/go-libp2p-udx-transport"
"github.com/stephanfeb/go-p2p-forge/codec"
)
func main() {
// os.Args[1]: the companion's full multiaddr, ending in /p2p/<peer ID>.
info, err := peer.AddrInfoFromString(os.Args[1])
if err != nil {
log.Fatal(err)
}
h, err := libp2p.New(
libp2p.NoTransports,
libp2p.Transport(udxtransport.NewTransport),
libp2p.ListenAddrStrings("/ip4/0.0.0.0/udp/0/udx"),
)
if err != nil {
log.Fatal(err)
}
defer h.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := h.Connect(ctx, *info); err != nil {
log.Fatal(err)
}
s, err := h.NewStream(ctx, info.ID, "/example/hello/1.0.0")
if err != nil {
log.Fatal(err)
}
defer s.Close()
req, _ := json.Marshal(map[string]string{"name": "ann"})
if err := codec.WriteFrame(s, req); err != nil {
log.Fatal(err)
}
reply, err := codec.ReadFrame(s)
if err != nil {
log.Fatal(err)
}
fmt.Println(string(reply)) // {"greeting":"hello, ann"}
}

The same host can also run pkg/client against your Ricochet server, so one app talks to both. See Go: your own libp2p host.

GossipSub forwards a topic only through peers that announce it. If your app’s peers connect only to your Ricochet server, they won’t hear the companion’s announcements unless Ricochet is in that topic’s mesh.

  1. Start the companion with Ricochet as its bootstrap peer, so it connects to the server at startup:

    Terminal window
    go run . -port 55224 -bootstrap /ip4/203.0.113.10/udp/55223/udx/p2p/<Ricochet peer ID>
  2. Tell Ricochet to relay the topic:

    /etc/ricochet/config.yaml
    pubsub:
    relay_topics:
    - /example/announce

Ricochet then forwards the topic without subscribing to it. See Relayed GossipSub topics for the rules and how to check it works.

The companion’s host is a normal libp2p host, so client.New(srv.Host(), cfg) gives it a pkg/client client after Start. It can then keep its state in documents, feeds or collections on your Ricochet server, or send mail to users, under its own peer ID and subject to the same access rules as any other peer.

Companion node Fork
Identity Its own peer ID. Clients address it separately. The server’s peer ID.
Storage Whatever you choose, or Ricochet through pkg/client as an ordinary peer. Direct access to go-ricochet’s PostgreSQL tables.
Process A second process to deploy, supervise and monitor. One process.
Operations You add your own logging, limits and metrics. Inherits the server’s access log, admission control, drain, rate limits and metrics.
Upgrades Upgrade Ricochet and the companion independently. Rebase on every Ricochet release.
Failure A crash in your code leaves Ricochet running. A bug in your code runs inside the server.

Choose a companion unless your feature has to touch go-ricochet’s own tables or answer on its peer ID. Then fork and add a protocol.