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 server
Section titled “The server”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.
go mod init example.com/companiongo 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.0package 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.Portis set and there are noListenAddresses, forge listens on/ip4/0.0.0.0/udp/<port>/udx. Withoutforge.WithTransport(libp2p.Transport(udxtransport.NewTransport))the host can’t parse that address andStartfails withunknown 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.
Run it
Section titled “Run it”go run . -port 55224On 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 ...]"How clients find it
Section titled “How clients find it”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:
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.
Share a topic with Ricochet
Section titled “Share a topic with Ricochet”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.
-
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> -
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 can use Ricochet too
Section titled “The companion can use Ricochet too”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.
When a companion beats a fork
Section titled “When a companion beats a fork”| 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.