openvibers/openvibe.link Commits

Pair through OpenVibe.Network with node tokens and bind on Bot

OP OpenVibers committed 4 Oct 2026 · f30a0c558f · 22 files, +1521 −96

Asked: T15 R6 — OpenVibe.Node: the B3 v2 Network-principal client (credentials/pairing: security-sensitive). ONE pull request from current origin/main (git fetch first; main includes S3/S4/S6b and is tagged v0.1.1; the local checkout is stale — read with git show origin/main:<path>). Background: Network m…
Harness job node-t15-r6-openvibe-node-the-075118 (plan T15).

Changes

ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex 08d12eb..2bf39bd 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -97,5 +97,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
-- - run: shellcheck -s sh install/install.sh scripts/dist.sh scripts/test-install-stock-server.sh
++ - run: shellcheck -s sh install/install.sh scripts/dist.sh scripts/test-install-stock-server.sh scripts/test-install-pair-args.sh
- run: sh scripts/test-install-stock-server.sh
++ - run: sh scripts/test-install-pair-args.sh
ddiff --git a/README.md b/README.md
iindex 87501ee..21d151e 100644
---- a/README.md
++++ b/README.md
@@@ -9,7 +9,8 @@ curl -fsSL https://openvibe.bot/install | sh -s -- --robot rob_… --code ABCD-1
```
openvibe.bot shows this command with the robot's id and a fresh code. `--driver` is `adeept`, `adeept-mecanum`, `cozmo`
--or `none` (dry run; the old `--robot adeept` form still works but is deprecated). Details, manual
++or `none` (dry run; the old `--robot adeept` form still works but is deprecated). A robot paired through
++OpenVibe.Network gets `--network <URL> --pairing pair_… --code ABCD-1234` instead of `--robot`. Details, manual
install, Windows and configuration: [docs/install.md](docs/install.md).
```sh
ddiff --git a/cmd/openvibe-node/main.go b/cmd/openvibe-node/main.go
iindex 08af9eb..3d58628 100644
---- a/cmd/openvibe-node/main.go
++++ b/cmd/openvibe-node/main.go
@@@ -1,6 +1,7 @@
// Command openvibe-node connects a computer, server, Raspberry Pi or robot to OpenVibe.
//
// openvibe-node pair <CODE> redeem a one-time pairing code and store the device credential
++// (--network/--pairing: pair on OpenVibe.Network and bind the device on Bot)
// openvibe-node credential import take a rotated credential: reads the rotate response JSON on stdin
// openvibe-node run run in the foreground (this is also what the service runs)
// openvibe-node install install and start the system service
@@@ -45,6 +46,7 @@ const usage = `openvibe-node %s: connect this device to OpenVibe.
Usage:
openvibe-node pair <CODE> [--robot rob_…] [--name NAME] [--server URL] [--kind onboard|bridge] [--force]
++ openvibe-node pair --network URL --pairing pair_… --code CODE [--name NAME] [--server URL] [--force]
openvibe-node credential import store the credential from a rotate response, read as JSON on stdin
openvibe-node run [--dry-run]
openvibe-node install [--user NAME]
@@@ -93,6 +95,8 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
robot = fs.String("robot", "", "the rob_… id of the robot to pair with, from the installer command (pair)")
name = fs.String("name", "", "a name for this device on openvibe.bot; default: the hostname (pair)")
codeArg = fs.String("code", "", "the pairing code, instead of the argument (pair)")
++ network = fs.String("network", "", "OpenVibe.Network origin: pair this machine there (pair)")
++ pairing = fs.String("pairing", "", "the pair_… id from the installer command (pair, with --network)")
force = fs.Bool("force", false, "pair again even if already paired")
dryRun = fs.Bool("dry-run", false, "run only the dry-run plugin and the test pattern (run)")
asJSON = fs.Bool("json", false, "print JSON (status, plugins)")
@@@ -119,11 +123,21 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
if *codeArg != "" {
positional = append(positional, *codeArg)
}
++ p := pairArgs{robot: *robot, name: *name, server: *server, kind: *kind, force: *force, network: *network, pairing: *pairing}
++ // --pairing with no code is the code itself (`--pairing ABCD-1234`); a pair_… id needs --code beside it.
++ if p.pairing != "" && len(positional) == 0 && !strings.HasPrefix(p.pairing, "pair_") {
++ positional, p.pairing = []string{p.pairing}, ""
++ }
if len(positional) != 1 {
-- fmt.Fprintln(stderr, "usage: openvibe-node pair <CODE> [--robot rob_…] [--name NAME]")
++ fmt.Fprintln(stderr, "usage: openvibe-node pair <CODE> [--robot rob_…] [--name NAME]\n openvibe-node pair --network URL --pairing pair_… --code CODE [--name NAME]")
return 2
}
-- err = cmdPair(g, pairArgs{code: positional[0], robot: *robot, name: *name, server: *server, kind: *kind, force: *force}, stdout)
++ p.code = positional[0]
++ if p.network != "" || p.pairing != "" {
++ err = cmdPairNetwork(g, p, stdout)
++ } else {
++ err = cmdPair(g, p, stdout)
++ }
case "credential":
if len(positional) != 1 || positional[0] != "import" {
fmt.Fprintln(stderr, "usage: openvibe-node credential import (reads the rotate response JSON on stdin)")
@@@ -159,6 +173,9 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
}
if err != nil {
fmt.Fprintln(stderr, "error:", err)
++ if errors.Is(err, link.ErrNodeRevoked) {
++ return service.ExitPairAgain
++ }
return 1
}
return 0
@@@ -212,46 +229,78 @@ func probeAll(cfg *config.Config, paths config.Paths, log *slog.Logger) (map[str
// pairArgs are the flags of `openvibe-node pair`.
type pairArgs struct {
code, robot, name, server, kind string
++ network, pairing string
force bool
}
// maxNameLen is the longest device name OpenVibe.Bot stores.
const maxNameLen = 80
--func cmdPair(g *globals, a pairArgs, stdout io.Writer) error {
-- code, err := link.NormalizeCode(a.code)
-- if err != nil {
-- return err
-- }
-- server, kind, force := a.server, a.kind, a.force
-- if a.robot != "" && !link.RobotRe.MatchString(a.robot) {
-- return fmt.Errorf("--robot must be a robot id like rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X (a driver kind such as %q goes in the config's plugins)", a.robot)
-- }
-- name := strings.TrimSpace(a.name)
++// deviceName is --name, else the hostname, cut to maxNameLen.
++func deviceName(name string) string {
++ name = strings.TrimSpace(name)
if name == "" {
name, _ = os.Hostname()
}
if r := []rune(name); len(r) > maxNameLen {
name = string(r[:maxNameLen])
}
++ return name
++}
++
++// preparePair checks that this Node may pair (not paired yet, or --force) and returns the config with --server and
++// --kind applied, and whether config.json was missing (then pairing writes it).
++func preparePair(g *globals, a pairArgs) (*config.Config, bool, error) {
if err := g.paths.Ensure(); err != nil {
-- return fmt.Errorf("cannot create %s (run with sudo, or use --home): %w", g.paths.ConfigDir, err)
++ return nil, false, fmt.Errorf("cannot create %s (run with sudo, or use --home): %w", g.paths.ConfigDir, err)
}
-- if old, err := credentials.Load(g.paths.CredentialFile(), nil); err == nil && !force {
-- return fmt.Errorf("already paired as %s; use --force to pair again (the old credential stops working once the owner removes it)", old.DeviceID)
++ if old, err := credentials.Load(g.paths.CredentialFile(), nil); err == nil && !a.force {
++ return nil, false, fmt.Errorf("already paired as %s; use --force to pair again (the old credential stops working once the owner removes it)", old.Label())
}
cfg, err := loadConfig(g)
if err != nil {
-- return err
++ return nil, false, err
}
_, statErr := os.Stat(g.paths.ConfigFile())
-- if server != "" {
-- cfg.Server = server
++ if a.server != "" {
++ cfg.Server = a.server
}
-- if kind != "" {
-- cfg.DeviceKind = kind
++ if a.kind != "" {
++ cfg.DeviceKind = a.kind
}
if err := cfg.Validate(); err != nil {
++ return nil, false, err
++ }
++ return cfg, statErr != nil, nil
++}
++
++// finishPair writes config.json when it was missing or --server/--kind changed it, and restarts a running service
++// so it picks up the new credential.
++func finishPair(g *globals, a pairArgs, cfg *config.Config, noConfig bool, stdout io.Writer) error {
++ if noConfig || a.server != "" || a.kind != "" {
++ if err := config.Save(g.paths.ConfigFile(), cfg); err != nil {
++ return err
++ }
++ chownLikeDir(g.paths.ConfigFile(), g.paths.ConfigDir)
++ }
++ if service.Status(serviceOptions(g, "")) == "running" {
++ _ = service.Control(serviceOptions(g, ""), "restart")
++ fmt.Fprintln(stdout, "The service was running and has been restarted with the new credential.")
++ }
++ return nil
++}
++
++func cmdPair(g *globals, a pairArgs, stdout io.Writer) error {
++ code, err := link.NormalizeCode(a.code)
++ if err != nil {
++ return err
++ }
++ if a.robot != "" && !link.RobotRe.MatchString(a.robot) {
++ return fmt.Errorf("--robot must be a robot id like rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X (a driver kind such as %q goes in the config's plugins)", a.robot)
++ }
++ name := deviceName(a.name)
++ cfg, noConfig, err := preparePair(g, a)
++ if err != nil {
return err
}
descs, _ := probeAll(cfg, g.paths, g.log)
@@@ -276,21 +325,92 @@ func cmdPair(g *globals, a pairArgs, stdout io.Writer) error {
return err
}
chownLikeDir(g.paths.CredentialFile(), g.paths.ConfigDir)
-- if statErr != nil || server != "" || kind != "" {
-- if err := config.Save(g.paths.ConfigFile(), cfg); err != nil {
++ fmt.Fprintf(stdout, "Paired as %s (robot %s). Credential saved to %s (mode 600).\n", creds.DeviceID, creds.RobotID, g.paths.CredentialFile())
++ fmt.Fprintln(stdout, "Confirm the device on openvibe.bot, then start it: `openvibe-node run` or `sudo openvibe-node install`.")
++ return finishPair(g, a, cfg, noConfig, stdout)
++}
++
++// cmdPairNetwork pairs through OpenVibe.Network: it redeems the code at <network>/api/v1/node-pairing, stores the
++// node principal and credential, then binds the device on Bot (POST /api/v1/devices/bind with a node token). A
++// failed bind keeps the credential: `openvibe-node run` binds on start.
++func cmdPairNetwork(g *globals, a pairArgs, stdout io.Writer) error {
++ code, err := link.NormalizeCode(a.code)
++ if err != nil {
++ return err
++ }
++ if a.pairing != "" && !link.PairingIDRe.MatchString(a.pairing) {
++ return errors.New("--pairing must be the pair_… id from the installer command on openvibe.bot")
++ }
++ network := strings.TrimSuffix(strings.TrimSpace(a.network), "/")
++ if network == "" {
++ return errors.New("--pairing needs --network, the OpenVibe.Network URL from the installer command")
++ }
++ cfg, noConfig, err := preparePair(g, a)
++ if err != nil {
++ return err
++ }
++ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
++ defer cancel()
++ ua := "openvibe-node/" + version
++ creds, err := link.PairNetwork(ctx, nil, network, protocol.NodePairingRequest{Code: code, Pairing: a.pairing, Name: deviceName(a.name)}, ua)
++ if err != nil {
++ return err
++ }
++ creds.Server = strings.TrimSuffix(cfg.Server, "/")
++ if err := saveCredentials(g, creds); err != nil {
++ return err
++ }
++ fmt.Fprintf(stdout, "Paired on %s as %s. Credential saved to %s (mode 600).\n", network, creds.Principal, g.paths.CredentialFile())
++ if bound, err := link.Bind(ctx, nil, creds, link.NewTokenSource(creds, nil, ua), ua); err != nil {
++ if errors.Is(err, link.ErrNodeRevoked) {
return err
}
-- chownLikeDir(g.paths.ConfigFile(), g.paths.ConfigDir)
++ fmt.Fprintf(stdout, "Could not bind the device on %s yet (%v); the node binds it when it starts.\n", creds.Server, err)
++ } else {
++ if err := saveCredentials(g, bound); err != nil {
++ return err
++ }
++ fmt.Fprintf(stdout, "Bound as %s (robot %s).\n", bound.DeviceID, bound.RobotID)
}
-- fmt.Fprintf(stdout, "Paired as %s (robot %s). Credential saved to %s (mode 600).\n", creds.DeviceID, creds.RobotID, g.paths.CredentialFile())
-- fmt.Fprintln(stdout, "Confirm the device on openvibe.bot, then start it: `openvibe-node run` or `sudo openvibe-node install`.")
-- if service.Status(serviceOptions(g, "")) == "running" {
-- _ = service.Control(serviceOptions(g, ""), "restart")
-- fmt.Fprintln(stdout, "The service was running and has been restarted with the new credential.")
++ fmt.Fprintln(stdout, "Start it: `openvibe-node run` or `sudo openvibe-node install`.")
++ return finishPair(g, a, cfg, noConfig, stdout)
++}
++
++func saveCredentials(g *globals, c *credentials.Credentials) error {
++ if err := credentials.Save(g.paths.CredentialFile(), c); err != nil {
++ return err
}
++ chownLikeDir(g.paths.CredentialFile(), g.paths.ConfigDir)
return nil
}
++// bindOnStart binds a Network-paired machine whose device record is missing (the bind at pairing failed) and saves
++// the answer. A refused node credential (ErrNodeRevoked) is returned; any other failure is logged and the Node
++// starts without a device record (no video); the next start binds again.
++func bindOnStart(g *globals, creds *credentials.Credentials, tokens *link.TokenSource, server string) (*credentials.Credentials, error) {
++ if !creds.NetworkPaired() || creds.Bound() {
++ return creds, nil
++ }
++ if creds.Server == "" {
++ creds.Server = server
++ }
++ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
++ defer cancel()
++ bound, err := link.Bind(ctx, nil, creds, tokens, "openvibe-node/"+version)
++ if errors.Is(err, link.ErrNodeRevoked) {
++ return nil, err
++ }
++ if err != nil {
++ g.log.Warn("could not bind the device on Bot; starting without video until the next start", "err", err)
++ return creds, nil
++ }
++ if err := saveCredentials(g, bound); err != nil {
++ return nil, err
++ }
++ g.log.Info("bound the device on Bot", "device", bound.DeviceID, "robot", bound.RobotID)
++ return bound, nil
++}
++
// maxRotateJSON caps what `credential import` reads from stdin: Bot's rotate answer is a few hundred bytes.
const maxRotateJSON = 64 << 10
@@@ -329,6 +449,9 @@ func cmdCredentialImport(g *globals, stdin io.Reader, stdout io.Writer) error {
if err != nil {
return err
}
++ if old.NetworkPaired() {
++ return errors.New("this Node is paired through OpenVibe.Network: it has no Bot credential to import")
++ }
if resp.Device.ID != old.DeviceID {
return fmt.Errorf("this rotation is for device %s, this Node is paired as %s", resp.Device.ID, old.DeviceID)
}
@@@ -368,7 +491,15 @@ func cmdRun(g *globals, dryRun bool) error {
if err != nil && !errors.Is(err, credentials.ErrNotPaired) {
return err
}
-- n, err := node.New(node.Options{Config: cfg, Paths: g.paths, Creds: creds, Log: g.log, Version: version})
++ var tokens link.Tokens
++ if creds.NetworkPaired() {
++ ts := link.NewTokenSource(creds, nil, "openvibe-node/"+version)
++ if creds, err = bindOnStart(g, creds, ts, cfg.Server); err != nil {
++ return err
++ }
++ tokens = ts
++ }
++ n, err := node.New(node.Options{Config: cfg, Paths: g.paths, Creds: creds, Tokens: tokens, Log: g.log, Version: version})
if err != nil {
return err
}
@@@ -454,7 +585,7 @@ func cmdStatus(g *globals, asJSON bool, stdout io.Writer) error {
}
fmt.Fprintf(stdout, "OpenVibe Node %s, running for %s (service: %s)\n", s.Version, s.Uptime, service.Status(serviceOptions(g, "")))
if s.Paired {
-- fmt.Fprintf(stdout, "Device: %s on %s\n", s.DeviceID, s.Server)
++ fmt.Fprintf(stdout, "Device: %s\n", deviceLine(s.DeviceID, s.Principal, s.Server))
} else {
fmt.Fprintln(stdout, "Device: not paired (openvibe-node pair <CODE>)")
}
@@@ -498,6 +629,18 @@ func cmdStatus(g *globals, asJSON bool, stdout io.Writer) error {
return nil
}
++// deviceLine is "dev_… on <server>", with the node principal of a Network-paired machine ("not bound yet" before
++// Bot has bound it).
++func deviceLine(device, principal, server string) string {
++ switch {
++ case principal == "":
++ return device + " on " + server
++ case device == "":
++ return principal + " (not bound yet) on " + server
++ }
++ return device + " (" + principal + ") on " + server
++}
++
func latchText(l safety.LatchState) string {
switch {
case l.Local && l.Remote:
@@@ -512,17 +655,18 @@ func latchText(l safety.LatchState) string {
func offlineStatus(g *globals, asJSON bool, stdout io.Writer) error {
type offline struct {
-- Running bool `json:"running"`
-- Service string `json:"service"`
-- Paired bool `json:"paired"`
-- DeviceID string `json:"device_id,omitempty"`
-- Server string `json:"server,omitempty"`
-- Latch safety.LatchState `json:"latch"`
-- Config string `json:"config_dir"`
++ Running bool `json:"running"`
++ Service string `json:"service"`
++ Paired bool `json:"paired"`
++ DeviceID string `json:"device_id,omitempty"`
++ Principal string `json:"principal,omitempty"`
++ Server string `json:"server,omitempty"`
++ Latch safety.LatchState `json:"latch"`
++ Config string `json:"config_dir"`
}
o := offline{Service: service.Status(serviceOptions(g, "")), Config: g.paths.ConfigDir}
if c, err := credentials.Load(g.paths.CredentialFile(), nil); err == nil {
-- o.Paired, o.DeviceID, o.Server = true, c.DeviceID, c.Server
++ o.Paired, o.DeviceID, o.Principal, o.Server = true, c.DeviceID, c.Principal, c.Server
}
if l, _ := safety.OpenLatch(g.paths.LatchFile()); l != nil {
o.Latch = l.State()
@@@ -532,7 +676,7 @@ func offlineStatus(g *globals, asJSON bool, stdout io.Writer) error {
}
fmt.Fprintf(stdout, "OpenVibe Node %s is not running (service: %s).\n", version, o.Service)
if o.Paired {
-- fmt.Fprintf(stdout, "Device: %s on %s\n", o.DeviceID, o.Server)
++ fmt.Fprintf(stdout, "Device: %s\n", deviceLine(o.DeviceID, o.Principal, o.Server))
} else {
fmt.Fprintln(stdout, "Device: not paired (openvibe-node pair <CODE>)")
}
ddiff --git a/cmd/openvibe-node/network_test.go b/cmd/openvibe-node/network_test.go
nnew file mode 100644
iindex 0000000..170a297
---- /dev/null
++++ b/cmd/openvibe-node/network_test.go
@@@ -0,0 +1,148 @@
++package main
++
++import (
++ "bytes"
++ "encoding/json"
++ "log/slog"
++ "net/http"
++ "os"
++ "path/filepath"
++ "runtime"
++ "strings"
++ "testing"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/config"
++ "github.com/OpenVibers/OpenVibe.Node/internal/credentials"
++ "github.com/OpenVibers/OpenVibe.Node/internal/fakebot"
++ "github.com/OpenVibers/OpenVibe.Node/internal/fakenetwork"
++ "github.com/OpenVibers/OpenVibe.Node/internal/link"
++ "github.com/OpenVibers/OpenVibe.Node/internal/service"
++)
++
++func fakes(t *testing.T) (*fakebot.Server, *fakenetwork.Server) {
++ t.Helper()
++ bot := fakebot.New()
++ t.Cleanup(bot.Close)
++ net := fakenetwork.New(bot)
++ t.Cleanup(net.Close)
++ net.AddCode("ABCD2345")
++ return bot, net
++}
++
++// noSecrets fails when the code, the node credential or a node token appears in any of outs.
++func noSecrets(t *testing.T, net *fakenetwork.Server, outs ...string) {
++ t.Helper()
++ all := strings.Join(outs, "\n")
++ for _, s := range append(net.Tokens(), net.Credential(), "ABCD2345", "ABCD-2345", "abcd-2345") {
++ if s != "" && strings.Contains(all, s) {
++ t.Fatalf("secret %q in output: %s", s, all)
++ }
++ }
++}
++
++// The installer's Network command: pair on Network, store the node credential (0600), bind on Bot.
++func TestPairNetwork(t *testing.T) {
++ bot, net := fakes(t)
++ home := shortHome(t)
++ if _, errOut, code := cli(t, "pair", "--network", net.URL(), "--pairing", "pair_bad", "--code", "abcd-2345", "--home", home); code == 0 || !strings.Contains(errOut, "pair_") {
++ t.Fatalf("bad pairing id accepted: %s", errOut)
++ }
++ out, errOut, code := cli(t, "pair", "--network", net.URL(), "--pairing", fakenetwork.PairingID, "--code", "abcd-2345", "--name", "Rover",
++ "--home", home, "--server", bot.URL())
++ if code != 0 {
++ t.Fatalf("pair failed: %s", errOut)
++ }
++ path := filepath.Join(home, "credential.json")
++ if st, _ := os.Stat(path); runtime.GOOS != "windows" && st.Mode().Perm() != 0o600 {
++ t.Fatalf("mode %o", st.Mode().Perm())
++ }
++ b, _ := os.ReadFile(path)
++ var raw map[string]any
++ _ = json.Unmarshal(b, &raw)
++ if raw["principal"] != fakenetwork.Principal || raw["node_credential"] != net.Credential() || raw["network"] != net.URL() ||
++ raw["paired_for"] == nil || raw["device_id"] != fakenetwork.DeviceID || raw["publish_key"] == "" || raw["server"] != bot.URL() {
++ t.Fatalf("credential file: %s", b)
++ }
++ if !strings.Contains(out, fakenetwork.Principal) || !strings.Contains(out, fakenetwork.DeviceID) {
++ t.Fatalf("output: %s", out)
++ }
++ if p := net.Pairings(); len(p) != 1 || p[0].Code != "ABCD2345" || p[0].Pairing != fakenetwork.PairingID || p[0].Name != "Rover" {
++ t.Fatalf("%+v", p)
++ }
++ if bot.Binds() != 1 || len(bot.PairRequests()) != 0 {
++ t.Fatalf("binds %d, legacy pairs %d", bot.Binds(), len(bot.PairRequests()))
++ }
++ status, _, _ := cli(t, "status", "--home", home)
++ if !strings.Contains(status, fakenetwork.DeviceID+" ("+fakenetwork.Principal+")") {
++ t.Fatalf("status: %s", status)
++ }
++ again, errAgain, code := cli(t, "pair", "--network", net.URL(), "--code", "abcd-2345", "--home", home)
++ if code == 0 || !strings.Contains(errAgain, "already paired as "+fakenetwork.DeviceID) {
++ t.Fatalf("second pair: %s", errAgain)
++ }
++ noSecrets(t, net, out, errOut, status, again, errAgain)
++}
++
++// `--pairing CODE` without --code is the code itself.
++func TestPairNetworkCodeAsPairing(t *testing.T) {
++ bot, net := fakes(t)
++ home := shortHome(t)
++ out, errOut, code := cli(t, "pair", "--network", net.URL(), "--pairing", "ABCD-2345", "--home", home, "--server", bot.URL())
++ if code != 0 || !strings.Contains(out, fakenetwork.DeviceID) {
++ t.Fatalf("%d %s %s", code, out, errOut)
++ }
++ if p := net.Pairings(); len(p) != 1 || p[0].Code != "ABCD2345" || p[0].Pairing != "" {
++ t.Fatalf("%+v", p)
++ }
++}
++
++// A bind that fails at pairing keeps the node credential; the next start binds and saves the device record.
++func TestBindOnStartWhenDeviceMissing(t *testing.T) {
++ bot, net := fakes(t)
++ home := shortHome(t)
++ bot.RefuseBind(http.StatusServiceUnavailable, "bot.unavailable", "try later")
++ out, errOut, code := cli(t, "pair", "--network", net.URL(), "--code", "ABCD-2345", "--home", home, "--server", bot.URL())
++ if code != 0 || !strings.Contains(out, "Could not bind") {
++ t.Fatalf("%d %s %s", code, out, errOut)
++ }
++ path := filepath.Join(home, "credential.json")
++ creds, err := credentials.Load(path, nil)
++ if err != nil || creds.Bound() || !creds.NetworkPaired() {
++ t.Fatalf("%v %+v", err, creds)
++ }
++ var logs bytes.Buffer
++ g := &globals{paths: config.DefaultPaths(home), log: slog.New(slog.NewTextHandler(&logs, nil))}
++ bound, err := bindOnStart(g, creds, link.NewTokenSource(creds, nil, "t"), bot.URL())
++ if err != nil || bound.DeviceID != fakenetwork.DeviceID {
++ t.Fatalf("%v %+v", err, bound)
++ }
++ if saved, _ := credentials.Load(path, nil); saved.DeviceID != fakenetwork.DeviceID || saved.PublishKey.IsZero() {
++ t.Fatalf("not saved: %+v", saved)
++ }
++ // Bound now: no second bind (each one would issue a new publish key).
++ if _, err := bindOnStart(g, bound, link.NewTokenSource(bound, nil, "t"), bot.URL()); err != nil || bot.Binds() != 2 {
++ t.Fatalf("%v binds %d", err, bot.Binds())
++ }
++ noSecrets(t, net, out, errOut, logs.String())
++}
++
++// A revoked node credential stops `run` at once with the re-pair message and the exit status the service unit does
++// not restart.
++func TestRunStopsWhenNodeCredentialRevoked(t *testing.T) {
++ bot, net := fakes(t)
++ home := shortHome(t)
++ bot.RefuseBind(http.StatusServiceUnavailable, "bot.unavailable", "try later")
++ if _, errOut, code := cli(t, "pair", "--network", net.URL(), "--code", "ABCD-2345", "--home", home, "--server", bot.URL()); code != 0 {
++ t.Fatal(errOut)
++ }
++ net.Revoke()
++ before := len(net.TokenForms())
++ out, errOut, code := cli(t, "run", "--home", home)
++ if code != service.ExitPairAgain || !strings.Contains(errOut, "pair again") {
++ t.Fatalf("%d %s %s", code, out, errOut)
++ }
++ if n := len(net.TokenForms()) - before; n != 1 {
++ t.Fatalf("%d token requests after the revoke, want 1", n)
++ }
++ noSecrets(t, net, out, errOut)
++}
ddiff --git a/docs/install.md b/docs/install.md
iindex c554716..19d7def 100644
---- a/docs/install.md
++++ b/docs/install.md
@@@ -20,6 +20,22 @@ ports, works behind any home router), and a local kill switch stops everything i
names the device on openvibe.bot (default: the hostname).
3. Confirm the new device on openvibe.bot.
++**Two pairing paths.** Which one the command uses is up to openvibe.bot; the installer takes both.
++
++- **Bot pairing** (the form above): `--robot rob_… --code CODE`. The Node redeems the code at
++ `POST <bot>/api/v1/pair` and stores the Bot device credential it answers with.
++- **Network pairing**: `--network <URL> --pairing pair_… --code CODE`, e.g.
++
++ ```sh
++ curl -fsSL https://openvibe.bot/install | sh -s -- --network https://openvibe.network --pairing pair_01J8Z4M2Q0R7T9YV3K6N8P1W2Y --code ABCD-1234 --driver adeept
++ ```
++
++ The Node redeems the code on OpenVibe.Network (`POST <network>/api/v1/node-pairing`) and stores the machine's node
++ principal (`nod_…`) and node credential. With it the Node buys short-lived node tokens (`POST <network>/oauth/token`,
++ audience `openvibe.bot`) and binds itself on Bot (`POST <bot>/api/v1/devices/bind`), which answers the device id, the
++ robot and the WHIP publish key. A bind that fails at pairing is retried when the Node starts. `--pairing CODE` with
++ no `--code` also works (the code is then the value of `--pairing`).
++
The installer detects the OS and CPU (linux amd64/arm64/armv7, macOS amd64/arm64), downloads the binary and the plugin
bundle and checks them against `SHA256SUMS`, creates a Python virtual environment for the plugins, writes
`config.json`, disables the Adeept kit's stock server if it finds it, creates the `openvibe-node` service account
@@@ -37,6 +53,8 @@ from plugins that send JPEG frames).
```sh
sudo install -m 755 openvibe-node-linux-arm64 /usr/local/bin/openvibe-node
sudo openvibe-node pair ABCD-1234 --robot rob_… # stores /etc/openvibe-node/credential.json (mode 600)
++# or, for a Network pairing:
++sudo openvibe-node pair --network https://openvibe.network --pairing pair_… --code ABCD-1234
sudo openvibe-node install # systemd / launchd / Windows service, starts it
openvibe-node status
```
@@@ -57,7 +75,7 @@ need Python; set `"python"` in `config.json`.
| command | what it does |
|-------------------------------------------|----------------------------------------------------------------------------|
--| `openvibe-node pair <CODE> [--force]` | redeem a pairing code (or `--code CODE`); `--robot rob_…` the robot it is for, `--name` the device name (default: hostname); `--server`, `--kind onboard\|bridge` override config |
++| `openvibe-node pair <CODE> [--force]` | redeem a pairing code (or `--code CODE`); `--robot rob_…` the robot it is for, `--name` the device name (default: hostname); `--server`, `--kind onboard\|bridge` override config. With `--network URL [--pairing pair_…]` the code is redeemed on OpenVibe.Network and the device bound on Bot |
| `openvibe-node run [--dry-run]` | run in the foreground (what the service runs) |
| `openvibe-node install [--user NAME]` | install and start the service |
| `openvibe-node uninstall` | stop and remove the service (config and credential are kept) |
@@@ -74,7 +92,7 @@ obeys within a quarter second and a starting Node obeys before anything moves. T
| Linux | macOS / Windows | contents |
|---------------------------------------|-------------------------------------------------------|-----------------------------------|
| `/etc/openvibe-node/config.json` | `…/OpenVibe Node/config.json` | configuration |
--| `/etc/openvibe-node/credential.json` | `…/OpenVibe Node/credential.json` | device credential (mode 600) |
++| `/etc/openvibe-node/credential.json` | `…/OpenVibe Node/credential.json` | device or node credential (mode 600) |
| `/var/lib/openvibe-node/latch.json` | `…/OpenVibe Node/latch.json` | e-stop / kill switch latch |
| `/var/lib/openvibe-node/node.sock` | `…/OpenVibe Node/node.sock` | local control socket (mode 600) |
| `/var/lib/openvibe-node/venv` | `…/OpenVibe Node/venv` | plugin Python environment |
@@@ -140,6 +158,9 @@ internet (Ethernet, or a second Wi-Fi adapter). Install with `--driver cozmo`; t
- `openvibe-node status` shows the last link error. "the server refused the device credential" means it was revoked:
`sudo openvibe-node pair --force <NEW-CODE>`.
++- A Network-paired machine whose node credential OpenVibe.Network refuses (revoked or removed) stops with "OpenVibe.Network
++ refused this machine's node credential" and exit status 78, which the systemd unit does not restart. Get a new
++ installer command on openvibe.bot and pair again with `--force`.
- Logs: `journalctl -u openvibe-node -f` (Linux), `/usr/local/var/log/openvibe-node.err.log` (macOS). On Windows the
service's log is not kept yet; stop the service and run `openvibe-node run` in a terminal to watch it.
- A plugin in `faulted` state says why in `status` (missing library, not a Raspberry Pi, robot not reachable, firmware).
ddiff --git a/docs/protocol.md b/docs/protocol.md
iindex d7f119c..3208c5f 100644
---- a/docs/protocol.md
++++ b/docs/protocol.md
@@@ -46,6 +46,48 @@ The credential and publish key are stored in `credential.json` (mode 0600, with
appear in a URL, a log line, an error, `openvibe-node status` or telemetry (the Go type `credentials.Secret` prints as
`[redacted]` in every format).
++### Pairing through OpenVibe.Network
++
++When the installer command carries `--network <URL> --pairing pair_… --code CODE` (Bot with
++`BOT_PAIRING_AUTHORITY=network`), the code is OpenVibe.Network's and the machine becomes a node principal there. Three
++calls, in order:
++
++```http
++POST <network>/api/v1/node-pairing
++Content-Type: application/json
++
++{"code": "ABCD1234", "pairing": "pair_…", "name": "rover"}
++```
++
++`201` → `{"principal": "nod_…", "node_id": "n-…", "home_cell": "…", "credential": "…", "token_endpoint": "…",
++"paired_for": {"service": "bot", "ref": "rob_…"}}`; refusals are problems with `registry.pairing_code_invalid`,
++`_locked`, `_used`, `_expired`, `registry.invalid_pairing_code`, `registry.invalid_pairing_request`, and a plain `429`.
++The Node stores `{principal, node_credential, network, paired_for}` in `credential.json` (mode 0600, temp file and
++rename) before anything else.
++
++```http
++POST <network>/oauth/token
++Content-Type: application/x-www-form-urlencoded
++
++grant_type=client_credentials&client_id=nod_…&client_secret=<node credential>&audience=openvibe.bot
++```
++
++`200` → `{"access_token": "<node token>", "token_type": "Bearer", "expires_in": 300}`. The audience is
++`openvibe.<paired_for.service>`. The Node caches the token until 30 s before it expires. `401` (`invalid_client`)
++means the credential is revoked or the machine removed: the Node stops with "pair again" and exit status 78, which the
++systemd unit does not restart (`RestartPreventExitStatus=78`). It never retries in a loop.
++
++```http
++POST <bot>/api/v1/devices/bind
++Authorization: Bearer <node token>
++```
++
++`201` → `{"device_id": "dev_…", "publish_key": "…", "whip_url": "…", "robot_id": "rob_…", "profile": {…}}`: the
++`POST /api/v1/pair` answer without a `credential`. The Node stores these fields exactly as it stores a pairing's. A
++`401` refetches the token and retries once. `403 bot.node_not_bound` means Bot will not bind this principal. Every
++bind issues a new publish key, so the Node binds once at pairing, and again at start only while the device record is
++missing (a bind that failed at pairing).
++
## 2. The control link
One WebSocket per device, opened by the device:
@@@ -65,7 +107,13 @@ arrives (Bot answers any frame on a not-yet-authenticated socket with `error` `b
| `4002`, or HTTP `401`/`403` | the credential is wrong, rotated or revoked | retries no sooner than 10 s (jitter only adds), logs "pair again, or import the owner's rotation" |
| `4003` | the owner revoked the device (or rotated its credential) while it was connected | the same as `4002` |
--The link never gives up on its own: the owner may pair the device again. Bot's `error` frames (`bot.not_paired`,
++A Network-paired machine presents a node token (`Authorization: Bearer <node token>`, audience `openvibe.bot`) instead
++of a credential. After a `4002` or `401` the next upgrade uses a freshly bought token. Once `hello` arrives the Node
++sends `reauth` with a fresh token every 240 s (Bot closes `4002` when none arrives within 330 s), and every 15 s while
++Network does not answer.
++
++The link never gives up on its own: the owner may pair the device again. The one exception is a Network-paired
++machine whose node credential Network refuses (`/oauth/token` `401`): the Node stops, as above. Bot's `error` frames (`bot.not_paired`,
`bot.not_ready`, `bot.forbidden`, …) are logged and never end the connection.
### Rotating a credential
@@@ -126,6 +174,7 @@ applied the Node runs nothing but `halt` (`nack not_ready`), so that latch is in
| `ack` | `id`; Node extra: `latency_ms` (receipt → plugin ack) |
| `nack` | `id`, `fault_code`; Node extra: `message` |
| `heartbeat` | `t` (send time, Unix ms), `rtt_ms` (the last measured round trip, once there is one; 0 is a real measurement) |
++| `reauth` | `token`: a fresh node token for the same principal (a Network-paired machine only; refused → `error` `bot.reauth_refused`) |
| `estop_state` | `latched` (the device is stopped: remote e-stop or local kill switch), `by` (`device`), `at`; Node extras: `robot_id` (when `hello` named exactly one robot), `local_stop` (local kill switch), `reason` |
The first `status` and `estop_state` go out once the connection's `config` has been applied, never right after the
ddiff --git a/install/install.sh b/install/install.sh
iindex 6af838e..bb9335d 100755
---- a/install/install.sh
++++ b/install/install.sh
@@@ -2,16 +2,19 @@
# OpenVibe Node installer.
#
# curl -fsSL https://openvibe.bot/install | sh -s -- --robot <rob_…> --code <CODE> [--driver adeept|adeept-mecanum|cozmo|none]
++# curl -fsSL https://openvibe.bot/install | sh -s -- --network <URL> --pairing <pair_…> --code <CODE> [--driver …]
#
# Detects the OS and CPU, downloads the openvibe-node binary and the plugin bundle (checking their SHA-256), creates
# a Python virtual environment for the plugins, disables the Adeept kit's stock control server if it finds it,
# installs the system service and pairs the device with <CODE>. Run it again to upgrade; the credential is kept.
--# openvibe.bot shows the first line with the robot id and a fresh code filled in.
++# openvibe.bot shows one of these lines with the robot id (or the Network pairing) and a fresh code filled in.
#
# Options:
# --robot ROBOT the rob_… id of the robot to pair with (from openvibe.bot). A driver kind here (adeept, …) is the
# old meaning of --robot: still accepted, but deprecated; use --driver.
# --code CODE the one-time pairing code (or give it as the first argument).
++# --network URL pair through OpenVibe.Network at URL (the machine gets a node credential, then Bot binds it).
++# --pairing ID the pair_… id of that Network pairing; a code given here instead (with no --code) is the code.
# --name NAME the device's name on openvibe.bot (default: the hostname).
# --driver KIND adeept (ordinary wheels), adeept-mecanum, cozmo (bridge), none (dry run). Default: none.
# --no-service install and pair, but do not install the service.
@@@ -27,6 +30,7 @@
# OPENVIBE_CRONTAB_ROOT=FILE edit FILE as root's crontab instead of the real one.
# OPENVIBE_CRONTAB_USER=FILE edit FILE as the invoking user's crontab instead of the real one.
# OPENVIBE_RC_LOCAL=FILE edit FILE as rc.local instead of /etc/rc.local.
++# OPENVIBE_INSTALL_PAIR_ARGS_ONLY=1 prints the `openvibe-node pair` arguments the flags make, one per line, and exits.
set -eu
REPO_URL="https://github.com/OpenVibers/OpenVibe.Node"
@@@ -34,14 +38,17 @@ CODE=""
DRIVER="none"
ROBOT_ID=""
NAME=""
++NETWORK=""
++PAIRING=""
SERVICE=1
LOCAL=""
TAG="latest"
usage() {
-- echo "usage: install.sh [--robot rob_…] [--code CODE | CODE] [--name NAME] [--driver adeept|adeept-mecanum|cozmo|none]"
-- echo " [--no-service] [--local DIR] [--version TAG]"
++ echo "usage: install.sh [--robot rob_… | --network URL --pairing pair_…] [--code CODE | CODE] [--name NAME]"
++ echo " [--driver adeept|adeept-mecanum|cozmo|none] [--no-service] [--local DIR] [--version TAG]"
echo " curl -fsSL https://openvibe.bot/install | sh -s -- --robot rob_… --code ABCD-1234 --driver adeept"
++ echo " curl -fsSL https://openvibe.bot/install | sh -s -- --network https://openvibe.network --pairing pair_… --code ABCD-1234"
}
say() { printf '%s\n' "openvibe-node: $*"; }
die() { printf '%s\n' "openvibe-node: error: $*" >&2; exit 1; }
@@@ -63,6 +70,10 @@ while [ $# -gt 0 ]; do
--code=*) CODE="${1#*=}"; shift ;;
--name) NAME="${2:-}"; shift 2 ;;
--name=*) NAME="${1#*=}"; shift ;;
++ --network) NETWORK="${2:-}"; shift 2 ;;
++ --network=*) NETWORK="${1#*=}"; shift ;;
++ --pairing) PAIRING="${2:-}"; shift 2 ;;
++ --pairing=*) PAIRING="${1#*=}"; shift ;;
--no-service) SERVICE=0; shift ;;
--local) LOCAL="${2:-}"; shift 2 ;;
--version) TAG="${2:-}"; shift 2 ;;
@@@ -74,6 +85,29 @@ done
case "$DRIVER" in adeept|adeept-mecanum|cozmo|none) ;; *) die "--driver must be adeept, adeept-mecanum, cozmo or none" ;; esac
case "$ROBOT_ID" in ""|rob_*) ;; *) die "--robot must be a rob_… id" ;; esac
++# --pairing takes the pair_… id; with no --code, anything else there is the code itself.
++case "$PAIRING" in ""|pair_*) ;; *) [ -z "$CODE" ] || die "--pairing must be a pair_… id"; CODE="$PAIRING"; PAIRING="" ;; esac
++if [ -n "$PAIRING" ] && [ -z "$NETWORK" ]; then die "--pairing needs --network, the OpenVibe.Network URL"; fi
++if [ -n "$NETWORK" ] && [ -z "$CODE" ]; then die "--network needs the pairing code (--code)"; fi
++case "$NETWORK" in ""|https://*|http://localhost*|http://127.0.0.1*) ;; *) die "--network must be an https:// URL" ;; esac
++
++# Run "$@" pair … with the pairing the flags gave: through Network with --network, else Bot's legacy code.
++pair_with() {
++ set -- "$@" pair "$CODE"
++ if [ -n "$NETWORK" ]; then
++ set -- "$@" --network "$NETWORK"
++ [ -z "$PAIRING" ] || set -- "$@" --pairing "$PAIRING"
++ else
++ [ -z "$ROBOT_ID" ] || set -- "$@" --robot "$ROBOT_ID"
++ fi
++ [ -z "$NAME" ] || set -- "$@" --name "$NAME"
++ "$@"
++}
++
++if [ "${OPENVIBE_INSTALL_PAIR_ARGS_ONLY:-0}" = 1 ]; then
++ pair_with printf '%s\n'
++ exit 0
++fi
# Root for the service, /usr/local/bin and /etc.
SUDO=""
@@@ -315,12 +349,9 @@ fi
# ---- pair ----
if [ -n "$CODE" ]; then
if $SUDO "$BIN_DIR/openvibe-node" status --json 2>/dev/null | grep -q '"paired":true'; then
-- say "already paired; keeping the credential (to pair again: sudo openvibe-node pair --force <CODE>)"
++ say "already paired; keeping the credential (to pair again: sudo openvibe-node pair --force <CODE>, adding --network <URL> --pairing <pair_…> for a Network pairing)"
else
-- set -- pair "$CODE"
-- [ -z "$ROBOT_ID" ] || set -- "$@" --robot "$ROBOT_ID"
-- [ -z "$NAME" ] || set -- "$@" --name "$NAME"
-- $SUDO "$BIN_DIR/openvibe-node" "$@"
++ pair_with $SUDO "$BIN_DIR/openvibe-node"
fi
else
say "not paired yet: get a code on openvibe.bot and run: sudo openvibe-node pair <CODE>"
ddiff --git a/internal/credentials/credentials.go b/internal/credentials/credentials.go
iindex 8d2b6f2..30c4871 100644
---- a/internal/credentials/credentials.go
++++ b/internal/credentials/credentials.go
@@@ -1,6 +1,11 @@
// Package credentials stores the device credential and publish key the pairing returned. The file is mode 0600 and
// the secret values are wrapped in Secret, whose every string form is "[redacted]", so they cannot reach a log line,
// an error or `openvibe-node status` by accident.
++//
++// Two kinds of pairing share the file. Legacy (OpenVibe.Bot's POST /api/v1/pair): a Bot device credential. Network
++// (OpenVibe.Network's POST /api/v1/node-pairing): a node principal and its node credential, which buys node tokens;
++// the device fields (device id, publish key, WHIP URL, robot, profile) come from Bot's POST /api/v1/devices/bind and
++// are empty until that bind has succeeded once.
package credentials
import (
@@@ -45,22 +50,32 @@ func (s *Secret) UnmarshalJSON([]byte) error {
type Credentials struct {
DeviceID string
RobotID string // rob_… the device was paired to
-- Credential Secret
-- PublishKey Secret // WHIP publish key; secret like the credential
-- Server string
-- DeviceURL string
-- WHIPURL string
-- ICEServers []protocol.ICEServer
-- Profile json.RawMessage
-- PairedAt time.Time
++ Credential Secret // a Bot device credential (legacy pairing); zero for a Network-paired machine
++ // Network pairing: the node principal (nod_…), its credential, the Network origin and what it was paired for.
++ Principal string
++ NodeCredential Secret
++ Network string
++ PairedFor *protocol.PairedFor
++ PublishKey Secret // WHIP publish key; secret like the credential
++ Server string
++ DeviceURL string
++ WHIPURL string
++ ICEServers []protocol.ICEServer
++ Profile json.RawMessage
++ PairedAt time.Time
}
// file is the on-disk form; only Save and Load see plain secrets.
type file struct {
-- Version int `json:"version"`
-- DeviceID string `json:"device_id"`
++ Version int `json:"version"`
++ Principal string `json:"principal,omitempty"`
++ NodeCredential string `json:"node_credential,omitempty"`
++ Network string `json:"network,omitempty"`
++ PairedFor *protocol.PairedFor `json:"paired_for,omitempty"`
++
++ DeviceID string `json:"device_id,omitempty"`
RobotID string `json:"robot_id,omitempty"`
-- Credential string `json:"credential"`
++ Credential string `json:"credential,omitempty"`
PublishKey string `json:"publish_key"`
Server string `json:"server"`
DeviceURL string `json:"device_url,omitempty"`
@@@ -70,16 +85,41 @@ type file struct {
PairedAt time.Time `json:"paired_at"`
}
++// NetworkPaired reports a machine paired through OpenVibe.Network: it authenticates with node tokens.
++func (c *Credentials) NetworkPaired() bool { return c != nil && c.Principal != "" }
++
++// Bound reports whether the device fields are present: always for a legacy pairing, after a successful
++// POST /api/v1/devices/bind for a Network one.
++func (c *Credentials) Bound() bool { return c != nil && c.DeviceID != "" }
++
++// Label names the pairing for messages: the device id, or the node principal before it is bound.
++func (c *Credentials) Label() string {
++ if c.DeviceID != "" {
++ return c.DeviceID
++ }
++ return c.Principal
++}
++
++// valid is the shape Save and Load accept: a device id and Bot credential, or a node principal, its credential and
++// the Network origin.
++func valid(principal, nodeCredential, network, deviceID, credential string) bool {
++ if principal != "" {
++ return nodeCredential != "" && network != ""
++ }
++ return deviceID != "" && credential != ""
++}
++
// ErrNotPaired is returned by Load when there is no credential file.
var ErrNotPaired = errors.New("this device is not paired; run `openvibe-node pair <CODE>`")
// Save writes the credentials atomically with mode 0600.
func Save(path string, c *Credentials) error {
-- if c.Credential.IsZero() || c.DeviceID == "" {
++ if !valid(c.Principal, c.NodeCredential.Reveal(), c.Network, c.DeviceID, c.Credential.Reveal()) {
return errors.New("credentials: refusing to save an empty credential")
}
b, err := json.MarshalIndent(file{
-- Version: 1, DeviceID: c.DeviceID, RobotID: c.RobotID, Credential: c.Credential.Reveal(), PublishKey: c.PublishKey.Reveal(),
++ Version: 1, Principal: c.Principal, NodeCredential: c.NodeCredential.Reveal(), Network: c.Network, PairedFor: c.PairedFor,
++ DeviceID: c.DeviceID, RobotID: c.RobotID, Credential: c.Credential.Reveal(), PublishKey: c.PublishKey.Reveal(),
Server: c.Server, DeviceURL: c.DeviceURL, WHIPURL: c.WHIPURL, ICEServers: c.ICEServers, Profile: c.Profile,
PairedAt: c.PairedAt,
}, "", " ")
@@@ -142,10 +182,11 @@ func Load(path string, warn func(string)) (*Credentials, error) {
// Never wrap the decoder error: it can quote file contents.
return nil, fmt.Errorf("credentials: %s is not valid JSON", path)
}
-- if f.DeviceID == "" || f.Credential == "" {
++ if !valid(f.Principal, f.NodeCredential, f.Network, f.DeviceID, f.Credential) {
return nil, fmt.Errorf("credentials: %s is incomplete; pair again", path)
}
return &Credentials{
++ Principal: f.Principal, NodeCredential: NewSecret(f.NodeCredential), Network: f.Network, PairedFor: f.PairedFor,
DeviceID: f.DeviceID, RobotID: f.RobotID, Credential: NewSecret(f.Credential), PublishKey: NewSecret(f.PublishKey),
Server: f.Server, DeviceURL: f.DeviceURL, WHIPURL: f.WHIPURL, ICEServers: f.ICEServers, Profile: f.Profile,
PairedAt: f.PairedAt,
ddiff --git a/internal/credentials/credentials_test.go b/internal/credentials/credentials_test.go
iindex e3ac3a5..a900a11 100644
---- a/internal/credentials/credentials_test.go
++++ b/internal/credentials/credentials_test.go
@@@ -9,6 +9,8 @@ import (
"runtime"
"strings"
"testing"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/protocol"
)
const secret = "s3cr3t-credential-value"
@@@ -83,3 +85,46 @@ func TestCorruptFileErrorHasNoContents(t *testing.T) {
t.Fatalf("err %v", err)
}
}
++
++// A Network pairing stores {principal, node_credential, network, paired_for} with mode 0600, before any device is
++// bound; neither secret prints.
++func TestSaveLoadNetworkPaired(t *testing.T) {
++ path := filepath.Join(t.TempDir(), "credential.json")
++ c := &Credentials{Principal: "nod_01J8Z4M2Q0R7T9YV3K6N8P1W2X", NodeCredential: NewSecret(secret), Network: "https://openvibe.network",
++ PairedFor: &protocol.PairedFor{Service: "bot", Ref: "rob_1"}, Server: "https://openvibe.bot"}
++ if s := fmt.Sprintf("%v %+v", c, *c); strings.Contains(s, secret) {
++ t.Fatalf("secret printed: %s", s)
++ }
++ if err := Save(path, c); err != nil {
++ t.Fatal(err)
++ }
++ if st, _ := os.Stat(path); runtime.GOOS != "windows" && st.Mode().Perm() != 0o600 {
++ t.Fatalf("mode %o", st.Mode().Perm())
++ }
++ b, _ := os.ReadFile(path)
++ var raw map[string]any
++ _ = json.Unmarshal(b, &raw)
++ if raw["principal"] != c.Principal || raw["node_credential"] != secret || raw["network"] != c.Network || raw["paired_for"] == nil {
++ t.Fatalf("file: %s", b)
++ }
++ if _, ok := raw["credential"]; ok {
++ t.Fatalf("a Network pairing has no Bot credential: %s", b)
++ }
++ got, err := Load(path, nil)
++ if err != nil {
++ t.Fatal(err)
++ }
++ if !got.NetworkPaired() || got.Bound() || got.NodeCredential.Reveal() != secret || got.PairedFor.Ref != "rob_1" || got.Label() != c.Principal {
++ t.Fatalf("%+v", got)
++ }
++ got.DeviceID, got.PublishKey = "dev_1", NewSecret("pk")
++ if err := Save(path, got); err != nil {
++ t.Fatal(err)
++ }
++ if again, _ := Load(path, nil); !again.Bound() || again.PublishKey.Reveal() != "pk" || again.NodeCredential.Reveal() != secret {
++ t.Fatalf("%+v", again)
++ }
++ if err := Save(path, &Credentials{Principal: c.Principal, Network: c.Network}); err == nil {
++ t.Fatal("saved a principal without its credential")
++ }
++}
ddiff --git a/internal/fakebot/fakebot.go b/internal/fakebot/fakebot.go
iindex 51bdc78..9302d1b 100644
---- a/internal/fakebot/fakebot.go
++++ b/internal/fakebot/fakebot.go
@@@ -51,7 +51,9 @@ type Server struct {
estop map[string]bool // robot id → the robot's e-stop latch, as Bot keeps it
pairRequests []protocol.PairRequest
pairRefusal *refusal
++ bindRefusals []refusal
credInURL bool
++ binds atomic.Int64
conns chan *Conn
count atomic.Int64
@@@ -76,6 +78,7 @@ func New() *Server {
mux := http.NewServeMux()
mux.HandleFunc(protocol.PairPath, s.pair)
mux.HandleFunc(protocol.DevicePath, s.device)
++ mux.HandleFunc(protocol.BindPath, s.bind)
mux.HandleFunc("/whip/", func(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
h := s.WHIP
@@@ -236,6 +239,49 @@ func (s *Server) pair(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(body)
}
++// RefuseBind makes the next POST /api/v1/devices/bind answer with this problem (queued: one refusal per call), as
++// Bot refuses a node token (401 bot.node_token_required) or a principal it will not bind (403 bot.node_not_bound).
++func (s *Server) RefuseBind(status int, code, detail string) {
++ s.mu.Lock()
++ s.bindRefusals = append(s.bindRefusals, refusal{status, code, detail})
++ s.mu.Unlock()
++}
++
++// Binds counts POST /api/v1/devices/bind requests.
++func (s *Server) Binds() int { return int(s.binds.Load()) }
++
++// bind is POST /api/v1/devices/bind: the bearer must be a registered credential (a node token the fake Network
++// registered); the answer is POST /pair's without the credential, with a new publish key every call.
++func (s *Server) bind(w http.ResponseWriter, r *http.Request) {
++ if r.Method != http.MethodPost {
++ problem(w, http.StatusMethodNotAllowed, "bot.method_not_allowed", "POST only")
++ return
++ }
++ s.binds.Add(1)
++ tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
++ s.mu.Lock()
++ if len(s.bindRefusals) > 0 {
++ rf := s.bindRefusals[0]
++ s.bindRefusals = s.bindRefusals[1:]
++ s.mu.Unlock()
++ problem(w, rf.status, rf.code, rf.detail)
++ return
++ }
++ dev, ok := s.creds[tok]
++ robot := s.robots[dev]
++ s.mu.Unlock()
++ if !ok {
++ problem(w, http.StatusUnauthorized, "bot.node_token_required", "a Network node token (audience openvibe.bot) is required")
++ return
++ }
++ pk := "pk_" + token()[:16]
++ w.Header().Set("Content-Type", "application/json")
++ w.Header().Set("Cache-Control", "no-store")
++ w.WriteHeader(http.StatusCreated)
++ _ = json.NewEncoder(w).Encode(protocol.PairResponse{DeviceID: dev, PublishKey: pk, WHIPURL: s.HTTP.URL + "/whip/" + pk, RobotID: robot,
++ Profile: json.RawMessage(`{"id":"sim.rover","limits":{"max_command_ms":300,"heartbeat_ms":1000}}`)})
++}
++
var upgrader = websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
func (s *Server) device(w http.ResponseWriter, r *http.Request) {
ddiff --git a/internal/fakenetwork/fakenetwork.go b/internal/fakenetwork/fakenetwork.go
nnew file mode 100644
iindex 0000000..563d496
---- /dev/null
++++ b/internal/fakenetwork/fakenetwork.go
@@@ -0,0 +1,173 @@
++// Package fakenetwork is an in-process stand-in for OpenVibe.Network's node-principal pairing, for tests: POST
++// /api/v1/node-pairing redeems a one-time code for a node principal and its credential, and POST /oauth/token
++// (client_credentials) sells node tokens for that credential (server/registry/node-principals.js,
++// server/auth/oauth-routes.js). Each token it mints is registered on the fake Bot as a credential of DeviceID, so the
++// Bot fake accepts it on /device and POST /api/v1/devices/bind as Bot accepts a node token.
++package fakenetwork
++
++import (
++ "crypto/rand"
++ "encoding/hex"
++ "encoding/json"
++ "net/http"
++ "net/http/httptest"
++ "strings"
++ "sync"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/fakebot"
++ "github.com/OpenVibers/OpenVibe.Node/internal/protocol"
++)
++
++// Principal and PairingID are what a redeemed code answers with; DeviceID is the device the fake Bot binds it to.
++const (
++ Principal = "nod_01J8Z4M2Q0R7T9YV3K6N8P1W2X"
++ PairingID = "pair_01J8Z4M2Q0R7T9YV3K6N8P1W2Y"
++ DeviceID = "dev_net01"
++)
++
++// Server is the fake Network.
++type Server struct {
++ HTTP *httptest.Server
++ // TTL is the expires_in of every token (seconds; default 300).
++ TTL int
++
++ bot *fakebot.Server
++
++ mu sync.Mutex
++ codes map[string]bool // normalized code → live
++ credential string // the node credential, once paired
++ revoked bool
++ tokens []string
++ pairings []protocol.NodePairingRequest
++ tokenForms []map[string]string
++}
++
++// New starts the fake Network; bot (may be nil) learns every token it mints.
++func New(bot *fakebot.Server) *Server {
++ s := &Server{TTL: 300, bot: bot, codes: map[string]bool{}}
++ mux := http.NewServeMux()
++ mux.HandleFunc(protocol.NodePairingPath, s.pair)
++ mux.HandleFunc(protocol.TokenPath, s.token)
++ s.HTTP = httptest.NewServer(mux)
++ return s
++}
++
++func (s *Server) Close() { s.HTTP.Close() }
++func (s *Server) URL() string { return s.HTTP.URL }
++
++// AddCode makes a one-time code (normalized, without the dash) live for PairingID.
++func (s *Server) AddCode(code string) {
++ s.mu.Lock()
++ s.codes[code] = true
++ s.mu.Unlock()
++}
++
++// Revoke makes /oauth/token refuse the node credential (401 invalid_client), as a revoked principal is.
++func (s *Server) Revoke() {
++ s.mu.Lock()
++ s.revoked = true
++ s.mu.Unlock()
++}
++
++// Credential is the node credential the last pairing handed out.
++func (s *Server) Credential() string {
++ s.mu.Lock()
++ defer s.mu.Unlock()
++ return s.credential
++}
++
++// Tokens are the node tokens minted so far, in order.
++func (s *Server) Tokens() []string {
++ s.mu.Lock()
++ defer s.mu.Unlock()
++ return append([]string(nil), s.tokens...)
++}
++
++// Pairings are the node-pairing request bodies received.
++func (s *Server) Pairings() []protocol.NodePairingRequest {
++ s.mu.Lock()
++ defer s.mu.Unlock()
++ return append([]protocol.NodePairingRequest(nil), s.pairings...)
++}
++
++// TokenForms are the /oauth/token form bodies received (client_secret included).
++func (s *Server) TokenForms() []map[string]string {
++ s.mu.Lock()
++ defer s.mu.Unlock()
++ return append([]map[string]string(nil), s.tokenForms...)
++}
++
++func random() string {
++ b := make([]byte, 24)
++ _, _ = rand.Read(b)
++ return hex.EncodeToString(b)
++}
++
++func problem(w http.ResponseWriter, status int, code string) {
++ w.Header().Set("Content-Type", "application/problem+json")
++ w.WriteHeader(status)
++ _ = json.NewEncoder(w).Encode(map[string]any{"status": status, "code": code, "title": http.StatusText(status), "detail": code})
++}
++
++func (s *Server) pair(w http.ResponseWriter, r *http.Request) {
++ var req protocol.NodePairingRequest
++ if r.Method != http.MethodPost || json.NewDecoder(r.Body).Decode(&req) != nil {
++ problem(w, http.StatusBadRequest, "registry.invalid_pairing_request")
++ return
++ }
++ code := strings.ToUpper(strings.ReplaceAll(req.Code, "-", ""))
++ s.mu.Lock()
++ s.pairings = append(s.pairings, req)
++ ok := s.codes[code] && (req.Pairing == "" || req.Pairing == PairingID)
++ if ok {
++ delete(s.codes, code)
++ s.credential, s.revoked = random(), false
++ }
++ cred := s.credential
++ s.mu.Unlock()
++ if !ok {
++ problem(w, http.StatusForbidden, "registry.pairing_code_invalid")
++ return
++ }
++ w.Header().Set("Content-Type", "application/json")
++ w.Header().Set("Cache-Control", "no-store")
++ w.WriteHeader(http.StatusCreated)
++ _ = json.NewEncoder(w).Encode(protocol.NodePairingResponse{Principal: Principal, NodeID: "n-01j8z4m2q0r7t9yv3k6n8p1w2x",
++ HomeCell: "eu-1", Credential: cred, TokenEndpoint: s.HTTP.URL + protocol.TokenPath,
++ PairedFor: &protocol.PairedFor{Service: "bot", Ref: fakebot.RobotID}})
++}
++
++func (s *Server) token(w http.ResponseWriter, r *http.Request) {
++ if r.Method != http.MethodPost || r.ParseForm() != nil {
++ w.WriteHeader(http.StatusBadRequest)
++ return
++ }
++ form := map[string]string{}
++ for k := range r.PostForm {
++ form[k] = r.PostForm.Get(k)
++ }
++ s.mu.Lock()
++ s.tokenForms = append(s.tokenForms, form)
++ good := !s.revoked && s.credential != "" && form["grant_type"] == "client_credentials" &&
++ form["client_id"] == Principal && form["client_secret"] == s.credential
++ s.mu.Unlock()
++ w.Header().Set("Content-Type", "application/json")
++ if !good {
++ w.WriteHeader(http.StatusUnauthorized)
++ _ = json.NewEncoder(w).Encode(protocol.OAuthError{Error: "invalid_client", Description: "Invalid client credentials"})
++ return
++ }
++ if form["audience"] != "openvibe.bot" {
++ w.WriteHeader(http.StatusBadRequest)
++ _ = json.NewEncoder(w).Encode(protocol.OAuthError{Error: "invalid_scope", Description: "a node token is for openvibe.network or openvibe.bot"})
++ return
++ }
++ tok := "eyJ" + random()
++ s.mu.Lock()
++ s.tokens = append(s.tokens, tok)
++ s.mu.Unlock()
++ if s.bot != nil {
++ s.bot.AddCredential(tok, DeviceID)
++ }
++ _ = json.NewEncoder(w).Encode(protocol.TokenResponse{AccessToken: tok, TokenType: "Bearer", ExpiresIn: s.TTL})
++}
ddiff --git a/internal/link/link.go b/internal/link/link.go
iindex 573785c..8afe5c7 100644
---- a/internal/link/link.go
++++ b/internal/link/link.go
@@@ -10,6 +10,10 @@
// 4002 (credential refused) and 4003 (revoked) keep retrying, never sooner than CredentialRetryMin, and log what to
// do: pair again, or import the owner's rotation. Bot's error frames are logged, never fatal.
//
++// A Network-paired machine (Options.Tokens) presents a node token instead of the credential: a fresh one on every
++// upgrade after a refusal, and a `reauth` frame with a fresh one every ReauthInterval (Bot closes 4002 when none
++// arrives within 330 s). Network refusing the node credential (ErrNodeRevoked) is the one error that ends Run.
++//
// Every server frame's ts also feeds an estimate of the server's clock (ServerNow), which the Node uses to read the
// absolute deadline_ms of a command.
package link
@@@ -51,9 +55,12 @@ type Handler interface {
type Options struct {
URL string // wss://openvibe.bot/device
Credential credentials.Secret
-- UserAgent string
-- Log *slog.Logger
-- Dialer *websocket.Dialer
++ // Tokens, when set, replaces Credential with node tokens (a Network-paired machine).
++ Tokens Tokens
++ ReauthInterval time.Duration // default 240 s: node tokens live 300 s
++ UserAgent string
++ Log *slog.Logger
++ Dialer *websocket.Dialer
HeartbeatInterval time.Duration // default 1 s
Deadman time.Duration // default 2 × heartbeat
@@@ -61,6 +68,12 @@ type Options struct {
BackoffMax time.Duration // default 30 s
}
++// Tokens supplies node tokens; *TokenSource is the real one.
++type Tokens interface {
++ Token(ctx context.Context) (credentials.Secret, error)
++ Invalidate()
++}
++
// CredentialRetryMin is the least a refused or revoked credential (close 4002 / 4003, HTTP 401 / 403) waits before
// the next try; jitter only ever adds to it.
const CredentialRetryMin = 10 * time.Second
@@@ -115,6 +128,9 @@ func New(opt Options, h Handler) *Link {
if opt.BackoffMax <= 0 {
opt.BackoffMax = 30 * time.Second
}
++ if opt.ReauthInterval <= 0 {
++ opt.ReauthInterval = 240 * time.Second
++ }
if opt.Log == nil {
opt.Log = slog.Default()
}
@@@ -236,18 +252,27 @@ func (l *Link) Send(m protocol.Message) error {
}
}
--// Run connects and reconnects until ctx ends. A refused or revoked credential does not end it: the owner may pair
--// the device again, and the Node retries every CredentialRetryMin or more, saying so in the log.
--func (l *Link) Run(ctx context.Context) {
++// Run connects and reconnects until ctx ends (it then returns nil). A refused or revoked credential does not end it:
++// the owner may pair the device again, and the Node retries every CredentialRetryMin or more, saying so in the log.
++// Only ErrNodeRevoked ends it early, returned: the node credential itself is dead and the machine must pair again.
++func (l *Link) Run(ctx context.Context) error {
attempt := 0
for ctx.Err() == nil {
start := time.Now()
err := l.session(ctx)
if ctx.Err() != nil {
-- return
++ return nil
}
msg := errMsg(err)
l.lastErr.Store(msg)
++ if errors.Is(err, ErrNodeRevoked) {
++ l.log.Error("link stopped: pair this machine again", "err", msg)
++ return err
++ }
++ if l.opt.Tokens != nil && credentialRefused(err) {
++ // Bot refused the token: the next upgrade presents a fresh one (bought with the node credential).
++ l.opt.Tokens.Invalidate()
++ }
if time.Since(start) > 30*time.Second {
attempt = 0
}
@@@ -261,10 +286,11 @@ func (l *Link) Run(ctx context.Context) {
}
select {
case <-ctx.Done():
-- return
++ return nil
case <-time.After(wait):
}
}
++ return nil
}
// backoff is the wait before reconnect attempt number attempt (0 first) after err: exponential with full jitter and
@@@ -319,8 +345,16 @@ func errMsg(err error) string {
}
func (l *Link) session(ctx context.Context) error {
++ cred := l.opt.Credential
++ if l.opt.Tokens != nil {
++ tok, err := l.opt.Tokens.Token(ctx)
++ if err != nil {
++ return err
++ }
++ cred = tok
++ }
h := http.Header{}
-- h.Set("Authorization", "Bearer "+l.opt.Credential.Reveal())
++ h.Set("Authorization", "Bearer "+cred.Reveal())
if l.opt.UserAgent != "" {
h.Set("User-Agent", l.opt.UserAgent)
}
@@@ -332,7 +366,7 @@ func (l *Link) session(ctx context.Context) error {
if resp != nil {
return fmt.Errorf("connect %s: HTTP %d", redactURL(l.opt.URL), resp.StatusCode)
}
-- return fmt.Errorf("connect %s: %s", redactURL(l.opt.URL), scrub(err.Error(), l.opt.Credential))
++ return fmt.Errorf("connect %s: %s", redactURL(l.opt.URL), scrub(err.Error(), cred))
}
defer conn.Close()
conn.SetReadLimit(1 << 20)
@@@ -424,6 +458,10 @@ func (l *Link) session(ctx context.Context) error {
}
}()
++ if l.opt.Tokens != nil {
++ go l.reauth(sessCtx, &up, end)
++ }
++
for {
_, data, err := conn.ReadMessage()
if err != nil {
@@@ -431,7 +469,7 @@ func (l *Link) session(ctx context.Context) error {
if ce := closeErr(err); ce != nil {
end(ce)
} else {
-- end(fmt.Errorf("read: %s", scrub(err.Error(), l.opt.Credential)))
++ end(fmt.Errorf("read: %s", scrub(err.Error(), cred)))
}
}
return endErr
@@@ -510,6 +548,41 @@ func (l *Link) session(ctx context.Context) error {
}
}
++// reauth sends a fresh node token every ReauthInterval once the connection is up, and retries a failed fetch every
++// 15 s (Bot's deadline is 330 s after the last token). Network refusing the credential ends the session.
++func (l *Link) reauth(ctx context.Context, up *atomic.Bool, end func(error)) {
++ timer := time.NewTimer(l.opt.ReauthInterval)
++ defer timer.Stop()
++ for {
++ select {
++ case <-ctx.Done():
++ return
++ case <-timer.C:
++ }
++ next := l.opt.ReauthInterval
++ if up.Load() {
++ l.opt.Tokens.Invalidate()
++ tok, err := l.opt.Tokens.Token(ctx)
++ switch {
++ case errors.Is(err, ErrNodeRevoked):
++ end(err)
++ return
++ case err != nil:
++ if ctx.Err() != nil {
++ return
++ }
++ l.log.Warn("reauth: no fresh node token yet", "err", err)
++ next = min(next, 15*time.Second)
++ default:
++ if err := l.Send(protocol.Reauth{Token: tok.Reveal()}); err != nil {
++ l.log.Warn("reauth not sent", "err", err)
++ }
++ }
++ }
++ timer.Reset(next)
++ }
++}
++
// DeviceURL derives wss://host/device from the server origin.
func DeviceURL(server string) (string, error) {
u, err := url.Parse(server)
ddiff --git a/internal/link/network.go b/internal/link/network.go
nnew file mode 100644
iindex 0000000..275373f
---- /dev/null
++++ b/internal/link/network.go
@@@ -0,0 +1,273 @@
++package link
++
++// The Network pairing (ADR-043 B3 v2): the machine redeems a one-time code on OpenVibe.Network for a node principal
++// (nod_…) and its node credential, buys short-lived node tokens with that credential, and presents a token (audience
++// openvibe.bot) to Bot: once to POST /api/v1/devices/bind for its device and WHIP publish key, then on every /device
++// upgrade and in `reauth` frames. Neither the code, the credential nor a token is ever logged or put in an error.
++
++import (
++ "bytes"
++ "context"
++ "encoding/json"
++ "errors"
++ "fmt"
++ "io"
++ "net/http"
++ "net/url"
++ "regexp"
++ "strings"
++ "sync"
++ "time"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/credentials"
++ "github.com/OpenVibers/OpenVibe.Node/internal/protocol"
++)
++
++// PairingIDRe matches a Network pairing id (pair_ + a ULID), the --pairing of the installer command.
++var PairingIDRe = regexp.MustCompile(`^pair_[0-9A-HJKMNP-TV-Z]{26}$`)
++
++// principalRe matches a node principal (nod_ + a ULID).
++var principalRe = regexp.MustCompile(`^nod_[0-9A-HJKMNP-TV-Z]{26}$`)
++
++// ErrNodeRevoked is Network refusing the node credential at /oauth/token (401 invalid_client): the machine was
++// revoked or removed. Retrying cannot help, so the Node stops and says how to pair again.
++var ErrNodeRevoked = errors.New("OpenVibe.Network refused this machine's node credential (revoked or removed): " +
++ "get a new installer command on openvibe.bot and pair again with `sudo openvibe-node pair --force --network <URL> --pairing <pair_…> --code <CODE>`")
++
++// TokenSkew is how long before its expiry a cached node token is replaced.
++const TokenSkew = 30 * time.Second
++
++// networkPairMessages turns Network's problem codes into what the person at the terminal should do next.
++var networkPairMessages = map[string]string{
++ "registry.invalid_pairing_code": "a pairing code is 8 letters and digits, like ABCD-1234",
++ "registry.invalid_pairing_request": "the pairing id or name is not valid; copy the installer command from openvibe.bot again",
++ "registry.pairing_code_invalid": "that is not the pairing code for this robot; check it, or ask for a new one on openvibe.bot",
++ "registry.pairing_code_locked": "too many wrong tries: the code is dead; ask for a new one on openvibe.bot",
++ "registry.pairing_code_used": "the pairing code was already used; ask for a new one on openvibe.bot",
++ "registry.pairing_code_expired": "the pairing code expired (codes last 10 minutes); ask for a new one on openvibe.bot",
++}
++
++// checkOrigin accepts an https:// origin, or plain http to a loopback host (tests and local servers).
++func checkOrigin(origin, what string) error {
++ u, err := url.Parse(origin)
++ if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" {
++ return fmt.Errorf("%s %q is not an https:// origin", what, origin)
++ }
++ if u.Scheme == "http" && !isLoopback(u.Hostname()) {
++ return fmt.Errorf("refusing to talk to %s over plain http", u.Host)
++ }
++ return nil
++}
++
++func defaultClient(c *http.Client) *http.Client {
++ if c == nil {
++ return &http.Client{Timeout: 20 * time.Second}
++ }
++ return c
++}
++
++// PairNetwork redeems a Network pairing code with POST <network>/api/v1/node-pairing. The result holds the node
++// principal and credential but no device yet: Bind fills that in.
++func PairNetwork(ctx context.Context, client *http.Client, network string, req protocol.NodePairingRequest, userAgent string) (*credentials.Credentials, error) {
++ network = strings.TrimSuffix(network, "/")
++ if err := checkOrigin(network, "network"); err != nil {
++ return nil, err
++ }
++ body, err := json.Marshal(req)
++ if err != nil {
++ return nil, err
++ }
++ hreq, err := http.NewRequestWithContext(ctx, http.MethodPost, network+protocol.NodePairingPath, bytes.NewReader(body))
++ if err != nil {
++ return nil, err
++ }
++ hreq.Header.Set("Content-Type", "application/json")
++ hreq.Header.Set("User-Agent", userAgent)
++ resp, err := defaultClient(client).Do(hreq)
++ if err != nil {
++ return nil, fmt.Errorf("pairing request failed: %w", err)
++ }
++ defer resp.Body.Close()
++ raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
++ if err != nil {
++ return nil, fmt.Errorf("pairing response: %w", err)
++ }
++ if resp.StatusCode/100 != 2 {
++ if resp.StatusCode == http.StatusTooManyRequests {
++ return nil, errors.New("too many pairing attempts from here; wait a minute and try again")
++ }
++ var pe protocol.Problem
++ _ = json.Unmarshal(raw, &pe)
++ if m, ok := networkPairMessages[pe.Code]; ok {
++ return nil, errors.New(m)
++ }
++ return nil, fmt.Errorf("pairing refused: HTTP %d %s %s", resp.StatusCode, pe.Code, problemText(pe))
++ }
++ var pr protocol.NodePairingResponse
++ if err := json.Unmarshal(raw, &pr); err != nil {
++ return nil, errors.New("pairing response is not valid JSON")
++ }
++ if !principalRe.MatchString(pr.Principal) || pr.Credential == "" {
++ return nil, errors.New("pairing response has no node principal or credential")
++ }
++ c := &credentials.Credentials{Principal: pr.Principal, NodeCredential: credentials.NewSecret(pr.Credential), Network: network,
++ PairedFor: pr.PairedFor, PairedAt: time.Now().UTC()}
++ if pr.PairedFor != nil {
++ c.RobotID = pr.PairedFor.Ref
++ }
++ return c, nil
++}
++
++func problemText(pe protocol.Problem) string {
++ if pe.Detail != "" {
++ return pe.Detail
++ }
++ return pe.Title
++}
++
++// Audience is the node-token audience for the service the machine was paired for: openvibe.<service>, Bot when
++// Network did not say.
++func Audience(c *credentials.Credentials) string {
++ if c.PairedFor != nil && c.PairedFor.Service != "" {
++ return "openvibe." + c.PairedFor.Service
++ }
++ return "openvibe.bot"
++}
++
++// TokenSource buys node tokens at POST <network>/oauth/token and caches one until TokenSkew before it expires.
++type TokenSource struct {
++ network string
++ principal string
++ credential credentials.Secret
++ audience string
++ client *http.Client
++ userAgent string
++ now func() time.Time
++
++ mu sync.Mutex
++ tok credentials.Secret
++ exp time.Time
++}
++
++// NewTokenSource returns the token source of a Network-paired machine.
++func NewTokenSource(c *credentials.Credentials, client *http.Client, userAgent string) *TokenSource {
++ return &TokenSource{network: strings.TrimSuffix(c.Network, "/"), principal: c.Principal, credential: c.NodeCredential,
++ audience: Audience(c), client: defaultClient(client), userAgent: userAgent, now: time.Now}
++}
++
++// Invalidate drops the cached token, so the next Token fetches a fresh one (after a 401 from Bot, before a reauth).
++func (s *TokenSource) Invalidate() {
++ s.mu.Lock()
++ s.tok, s.exp = credentials.Secret{}, time.Time{}
++ s.mu.Unlock()
++}
++
++// Token returns a node token, cached or fresh. A refused credential is ErrNodeRevoked.
++func (s *TokenSource) Token(ctx context.Context) (credentials.Secret, error) {
++ s.mu.Lock()
++ defer s.mu.Unlock()
++ if !s.tok.IsZero() && s.now().Before(s.exp) {
++ return s.tok, nil
++ }
++ if err := checkOrigin(s.network, "network"); err != nil {
++ return credentials.Secret{}, err
++ }
++ form := url.Values{"grant_type": {"client_credentials"}, "client_id": {s.principal},
++ "client_secret": {s.credential.Reveal()}, "audience": {s.audience}}
++ hreq, err := http.NewRequestWithContext(ctx, http.MethodPost, s.network+protocol.TokenPath, strings.NewReader(form.Encode()))
++ if err != nil {
++ return credentials.Secret{}, err
++ }
++ hreq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
++ hreq.Header.Set("Accept", "application/json")
++ hreq.Header.Set("User-Agent", s.userAgent)
++ resp, err := s.client.Do(hreq)
++ if err != nil {
++ return credentials.Secret{}, fmt.Errorf("node token request failed: %w", err)
++ }
++ defer resp.Body.Close()
++ raw, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
++ if err != nil {
++ return credentials.Secret{}, fmt.Errorf("node token response: %w", err)
++ }
++ switch {
++ case resp.StatusCode == http.StatusUnauthorized:
++ return credentials.Secret{}, ErrNodeRevoked
++ case resp.StatusCode/100 != 2:
++ var oe protocol.OAuthError
++ _ = json.Unmarshal(raw, &oe)
++ return credentials.Secret{}, fmt.Errorf("node token refused: HTTP %d %s %s", resp.StatusCode, oe.Error, oe.Description)
++ }
++ var tr protocol.TokenResponse
++ if err := json.Unmarshal(raw, &tr); err != nil || tr.AccessToken == "" {
++ return credentials.Secret{}, errors.New("node token response has no access_token")
++ }
++ ttl := time.Duration(tr.ExpiresIn) * time.Second
++ if ttl <= 0 {
++ ttl = time.Minute
++ }
++ s.tok, s.exp = credentials.NewSecret(tr.AccessToken), s.now().Add(max(ttl-TokenSkew, ttl/2))
++ return s.tok, nil
++}
++
++// bindMessages turns Bot's refusals of POST /api/v1/devices/bind into what to do next.
++var bindMessages = map[string]string{
++ "bot.node_token_required": "Bot did not take the node token (audience openvibe.bot)",
++ "bot.node_not_bound": "Bot will not bind this machine (revoked there, paired for another service or for someone else's robot): pair it again with a new code from openvibe.bot",
++}
++
++// Bind calls POST <bot>/api/v1/devices/bind with a node token and returns c with the answer's device fields (device
++// id, robot, WHIP publish key and URL, profile), exactly as Pair returns them. A 401 refetches the token once.
++// Every call issues a new publish key (the old one stops working), so bind only when the device record is missing.
++func Bind(ctx context.Context, client *http.Client, c *credentials.Credentials, tokens *TokenSource, userAgent string) (*credentials.Credentials, error) {
++ server := strings.TrimSuffix(c.Server, "/")
++ if err := checkOrigin(server, "server"); err != nil {
++ return nil, err
++ }
++ client = defaultClient(client)
++ for attempt := 0; ; attempt++ {
++ tok, err := tokens.Token(ctx)
++ if err != nil {
++ return nil, err
++ }
++ hreq, err := http.NewRequestWithContext(ctx, http.MethodPost, server+protocol.BindPath, nil)
++ if err != nil {
++ return nil, err
++ }
++ hreq.Header.Set("Authorization", "Bearer "+tok.Reveal())
++ hreq.Header.Set("User-Agent", userAgent)
++ resp, err := client.Do(hreq)
++ if err != nil {
++ return nil, fmt.Errorf("bind request failed: %w", err)
++ }
++ raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
++ resp.Body.Close()
++ if err != nil {
++ return nil, fmt.Errorf("bind response: %w", err)
++ }
++ if resp.StatusCode == http.StatusUnauthorized && attempt == 0 {
++ tokens.Invalidate()
++ continue
++ }
++ if resp.StatusCode/100 != 2 {
++ var pe protocol.Problem
++ _ = json.Unmarshal(raw, &pe)
++ if m, ok := bindMessages[pe.Code]; ok {
++ return nil, errors.New(m)
++ }
++ return nil, fmt.Errorf("bind refused: HTTP %d %s %s", resp.StatusCode, pe.Code, problemText(pe))
++ }
++ var pr protocol.PairResponse
++ if err := json.Unmarshal(raw, &pr); err != nil {
++ return nil, errors.New("bind response is not valid JSON")
++ }
++ if pr.DeviceID == "" {
++ return nil, errors.New("bind response has no device id")
++ }
++ next := *c
++ next.Server = server
++ next.DeviceID, next.RobotID, next.PublishKey, next.WHIPURL, next.Profile =
++ pr.DeviceID, pr.RobotID, credentials.NewSecret(pr.PublishKey), pr.WHIPURL, pr.Profile
++ return &next, nil
++ }
++}
ddiff --git a/internal/link/network_test.go b/internal/link/network_test.go
nnew file mode 100644
iindex 0000000..29b42ec
---- /dev/null
++++ b/internal/link/network_test.go
@@@ -0,0 +1,182 @@
++package link
++
++import (
++ "bytes"
++ "context"
++ "errors"
++ "log/slog"
++ "net/http"
++ "strings"
++ "sync"
++ "testing"
++ "time"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/credentials"
++ "github.com/OpenVibers/OpenVibe.Node/internal/fakebot"
++ "github.com/OpenVibers/OpenVibe.Node/internal/fakenetwork"
++ "github.com/OpenVibers/OpenVibe.Node/internal/protocol"
++)
++
++func networkPair(t *testing.T) (*fakebot.Server, *fakenetwork.Server, *credentials.Credentials) {
++ t.Helper()
++ bot := fakebot.New()
++ t.Cleanup(bot.Close)
++ net := fakenetwork.New(bot)
++ t.Cleanup(net.Close)
++ net.AddCode("ABCD2345")
++ c, err := PairNetwork(context.Background(), nil, net.URL()+"/",
++ protocol.NodePairingRequest{Code: "ABCD2345", Pairing: fakenetwork.PairingID, Name: "Rover"}, "openvibe-node/t")
++ if err != nil {
++ t.Fatal(err)
++ }
++ c.Server = bot.URL()
++ return bot, net, c
++}
++
++// The wire sequence: node-pairing → oauth/token (client_credentials, audience openvibe.bot) → devices/bind with the
++// token; the bind answer fills the device fields exactly as POST /pair does.
++func TestPairNetworkTokenAndBind(t *testing.T) {
++ bot, net, c := networkPair(t)
++ if c.Principal != fakenetwork.Principal || c.NodeCredential.Reveal() != net.Credential() || c.Network != net.URL() ||
++ c.PairedFor == nil || c.PairedFor.Service != "bot" || c.RobotID != fakebot.RobotID || c.Bound() || !c.NetworkPaired() {
++ t.Fatalf("%+v", c)
++ }
++ if p := net.Pairings(); len(p) != 1 || p[0].Code != "ABCD2345" || p[0].Pairing != fakenetwork.PairingID || p[0].Name != "Rover" {
++ t.Fatalf("%+v", p)
++ }
++ ts := NewTokenSource(c, nil, "openvibe-node/t")
++ bound, err := Bind(context.Background(), nil, c, ts, "openvibe-node/t")
++ if err != nil {
++ t.Fatal(err)
++ }
++ if bound.DeviceID != fakenetwork.DeviceID || bound.RobotID != fakebot.RobotID || bound.PublishKey.IsZero() ||
++ !strings.Contains(bound.WHIPURL, bound.PublishKey.Reveal()) || len(bound.Profile) == 0 || bound.Principal != c.Principal {
++ t.Fatalf("%+v", bound)
++ }
++ forms := net.TokenForms()
++ if len(forms) != 1 || forms[0]["grant_type"] != "client_credentials" || forms[0]["client_id"] != fakenetwork.Principal ||
++ forms[0]["client_secret"] != net.Credential() || forms[0]["audience"] != "openvibe.bot" {
++ t.Fatalf("%+v", forms)
++ }
++ // Cached: a second Token is the same, with no new request.
++ if tok, _ := ts.Token(context.Background()); tok.Reveal() != net.Tokens()[0] || len(net.TokenForms()) != 1 {
++ t.Fatal("token not cached")
++ }
++ if bot.Binds() != 1 {
++ t.Fatalf("binds %d", bot.Binds())
++ }
++}
++
++func TestTokenCachedUntilExpiryMinusSkew(t *testing.T) {
++ _, net, c := networkPair(t)
++ ts := NewTokenSource(c, nil, "t")
++ now := time.Now()
++ ts.now = func() time.Time { return now }
++ a, err := ts.Token(context.Background())
++ if err != nil {
++ t.Fatal(err)
++ }
++ now = now.Add(300*time.Second - TokenSkew - time.Second)
++ if b, _ := ts.Token(context.Background()); b.Reveal() != a.Reveal() {
++ t.Fatal("refetched before expiry minus skew")
++ }
++ now = now.Add(2 * time.Second)
++ if b, _ := ts.Token(context.Background()); b.Reveal() == a.Reveal() || len(net.Tokens()) != 2 {
++ t.Fatal("not refetched at expiry minus skew")
++ }
++}
++
++// A revoked principal: /oauth/token answers 401 invalid_client, which is ErrNodeRevoked, and the error never quotes
++// the credential.
++func TestTokenRevoked(t *testing.T) {
++ _, net, c := networkPair(t)
++ net.Revoke()
++ _, err := NewTokenSource(c, nil, "t").Token(context.Background())
++ if !errors.Is(err, ErrNodeRevoked) || strings.Contains(err.Error(), net.Credential()) || !strings.Contains(err.Error(), "pair again") {
++ t.Fatalf("%v", err)
++ }
++ if _, err := Bind(context.Background(), nil, c, NewTokenSource(c, nil, "t"), "t"); !errors.Is(err, ErrNodeRevoked) {
++ t.Fatalf("bind: %v", err)
++ }
++}
++
++// Bot answering 401 to the bind refetches the token once, and only once.
++func TestBindRefetchesTokenOnceOn401(t *testing.T) {
++ bot, net, c := networkPair(t)
++ bot.RefuseBind(http.StatusUnauthorized, "bot.node_token_required", "")
++ if _, err := Bind(context.Background(), nil, c, NewTokenSource(c, nil, "t"), "t"); err != nil {
++ t.Fatal(err)
++ }
++ if bot.Binds() != 2 || len(net.Tokens()) != 2 {
++ t.Fatalf("binds %d tokens %d", bot.Binds(), len(net.Tokens()))
++ }
++ bot.RefuseBind(http.StatusUnauthorized, "bot.node_token_required", "")
++ bot.RefuseBind(http.StatusUnauthorized, "bot.node_token_required", "")
++ _, err := Bind(context.Background(), nil, c, NewTokenSource(c, nil, "t"), "t")
++ if err == nil || !strings.Contains(err.Error(), "node token") || bot.Binds() != 4 {
++ t.Fatalf("%v binds %d", err, bot.Binds())
++ }
++ bot.RefuseBind(http.StatusForbidden, "bot.node_not_bound", "")
++ if _, err := Bind(context.Background(), nil, c, NewTokenSource(c, nil, "t"), "t"); err == nil || !strings.Contains(err.Error(), "will not bind") {
++ t.Fatalf("%v", err)
++ }
++}
++
++func TestPairNetworkRefusals(t *testing.T) {
++ net := fakenetwork.New(nil)
++ defer net.Close()
++ _, err := PairNetwork(context.Background(), nil, net.URL(), protocol.NodePairingRequest{Code: "ABCD2345"}, "t")
++ if err == nil || !strings.Contains(err.Error(), "not the pairing code") {
++ t.Fatalf("%v", err)
++ }
++ if _, err := PairNetwork(context.Background(), nil, "http://example.com", protocol.NodePairingRequest{Code: "ABCD2345"}, "t"); err == nil || !strings.Contains(err.Error(), "plain http") {
++ t.Fatalf("%v", err)
++ }
++}
++
++// A Network-paired link presents node tokens, sends reauth with a fresh one, and stops (Run returns ErrNodeRevoked)
++// once Network refuses the credential. Nothing secret reaches the log.
++func TestNodeTokenLinkReauthAndRevoked(t *testing.T) {
++ bot, net, c := networkPair(t)
++ u, _ := DeviceURL(bot.URL())
++ var logs bytes.Buffer
++ lw := &lockedWriter{&logs, &sync.Mutex{}}
++ rec := newRecorder()
++ l := New(Options{URL: u, Tokens: NewTokenSource(c, nil, "t"), ReauthInterval: 200 * time.Millisecond, HeartbeatInterval: 100 * time.Millisecond,
++ BackoffMin: 20 * time.Millisecond, Log: slog.New(slog.NewTextHandler(lw, nil))}, rec)
++ done := make(chan error, 1)
++ ctx, cancel := context.WithCancel(context.Background())
++ defer cancel()
++ go func() { done <- l.Run(ctx) }()
++ conn, err := bot.NextConn(3 * time.Second)
++ if err != nil {
++ t.Fatal(err)
++ }
++ waitFrame(t, rec, protocol.TypeConfig)
++ f, err := conn.Expect(protocol.TypeReauth, 3*time.Second, nil)
++ if err != nil {
++ t.Fatal(err)
++ }
++ toks := net.Tokens()
++ if len(toks) < 2 || f.Msg.(protocol.Reauth).Token != toks[len(toks)-1] {
++ t.Fatalf("reauth %+v, tokens %d", f.Msg, len(toks))
++ }
++ net.Revoke()
++ select {
++ case err := <-done:
++ if !errors.Is(err, ErrNodeRevoked) {
++ t.Fatalf("Run returned %v", err)
++ }
++ case <-time.After(5 * time.Second):
++ t.Fatal("Run did not stop on a revoked credential")
++ }
++ if !strings.Contains(l.Stats().LastError, "pair again") {
++ t.Fatalf("last error %q", l.Stats().LastError)
++ }
++ out := lw.String()
++ for _, secret := range append(net.Tokens(), net.Credential()) {
++ if strings.Contains(out, secret) {
++ t.Fatalf("secret in logs: %s", out)
++ }
++ }
++}
ddiff --git a/internal/link/pair.go b/internal/link/pair.go
iindex 55c5bf8..076dc65 100644
---- a/internal/link/pair.go
++++ b/internal/link/pair.go
@@@ -8,7 +8,6 @@ import (
"fmt"
"io"
"net/http"
-- "net/url"
"regexp"
"strings"
"time"
@@@ -46,12 +45,8 @@ var pairMessages = map[string]string{
// Pair redeems a one-time code with POST <server>/api/v1/pair.
func Pair(ctx context.Context, client *http.Client, server string, req protocol.PairRequest) (*credentials.Credentials, error) {
-- u, err := url.Parse(server)
-- if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" {
-- return nil, fmt.Errorf("server %q is not an https:// origin", server)
-- }
-- if u.Scheme == "http" && !isLoopback(u.Hostname()) {
-- return nil, fmt.Errorf("refusing to pair over plain http with %s", u.Host)
++ if err := checkOrigin(server, "server"); err != nil {
++ return nil, err
}
body, err := json.Marshal(req)
if err != nil {
@@@ -64,10 +59,7 @@ func Pair(ctx context.Context, client *http.Client, server string, req protocol.
}
hreq.Header.Set("Content-Type", "application/json")
hreq.Header.Set("User-Agent", "openvibe-node/"+req.AgentVersion)
-- if client == nil {
-- client = &http.Client{Timeout: 20 * time.Second}
-- }
-- resp, err := client.Do(hreq)
++ resp, err := defaultClient(client).Do(hreq)
if err != nil {
return nil, fmt.Errorf("pairing request failed: %w", err)
}
ddiff --git a/internal/node/network_test.go b/internal/node/network_test.go
nnew file mode 100644
iindex 0000000..3799aab
---- /dev/null
++++ b/internal/node/network_test.go
@@@ -0,0 +1,69 @@
++package node
++
++import (
++ "context"
++ "errors"
++ "io"
++ "log/slog"
++ "os"
++ "testing"
++ "time"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/config"
++ "github.com/OpenVibers/OpenVibe.Node/internal/fakebot"
++ "github.com/OpenVibers/OpenVibe.Node/internal/fakenetwork"
++ "github.com/OpenVibers/OpenVibe.Node/internal/link"
++ "github.com/OpenVibers/OpenVibe.Node/internal/protocol"
++)
++
++// A Network-paired Node connects with node tokens and stops on its own, with link.ErrNodeRevoked, once Network
++// refuses its credential: no retry loop.
++func TestNetworkPairedNodeStopsWhenRevoked(t *testing.T) {
++ home, err := os.MkdirTemp("", "ovn")
++ if err != nil {
++ t.Fatal(err)
++ }
++ t.Cleanup(func() { os.RemoveAll(home) })
++ bot := fakebot.New()
++ t.Cleanup(bot.Close)
++ net := fakenetwork.New(bot)
++ t.Cleanup(net.Close)
++ net.AddCode("TEST2345")
++ creds, err := link.PairNetwork(context.Background(), nil, net.URL(), protocol.NodePairingRequest{Code: "TEST2345"}, "t")
++ if err != nil {
++ t.Fatal(err)
++ }
++ creds.Server = bot.URL()
++ if creds, err = link.Bind(context.Background(), nil, creds, link.NewTokenSource(creds, nil, "t"), "t"); err != nil {
++ t.Fatal(err)
++ }
++ cfg := config.Default()
++ cfg.Plugins = nil
++ cfg.Video.Source = "off"
++ n, err := New(Options{Config: cfg, Paths: config.HomePaths(home), Creds: creds, Version: "test", HeartbeatInterval: 100 * time.Millisecond,
++ ReauthInterval: 200 * time.Millisecond, LinkBackoffMin: 50 * time.Millisecond, LatchPoll: 50 * time.Millisecond,
++ Log: slog.New(slog.NewTextHandler(io.Discard, nil))})
++ if err != nil {
++ t.Fatal(err)
++ }
++ ctx, cancel := context.WithCancel(context.Background())
++ defer cancel()
++ done := make(chan error, 1)
++ go func() { done <- n.Run(ctx) }()
++ conn, err := bot.NextConn(5 * time.Second)
++ if err != nil {
++ t.Fatal(err)
++ }
++ if _, err := conn.Expect(protocol.TypeReauth, 3*time.Second, nil); err != nil {
++ t.Fatal(err)
++ }
++ net.Revoke()
++ select {
++ case err := <-done:
++ if !errors.Is(err, link.ErrNodeRevoked) {
++ t.Fatalf("Run returned %v", err)
++ }
++ case <-time.After(10 * time.Second):
++ t.Fatal("the node kept running with a revoked credential")
++ }
++}
ddiff --git a/internal/node/node.go b/internal/node/node.go
iindex 140bcb0..6930691 100644
---- a/internal/node/node.go
++++ b/internal/node/node.go
@@@ -43,7 +43,11 @@ type Options struct {
Log *slog.Logger
Version string
++ // Tokens are a Network-paired machine's node tokens; nil builds them from Creds.
++ Tokens link.Tokens
++
// For tests.
++ ReauthInterval time.Duration
HeartbeatInterval time.Duration
LinkBackoffMin time.Duration
VideoLoopback bool
@@@ -133,8 +137,18 @@ func New(opt Options) (*Node, error) {
return nil, err
}
}
-- n.link = link.New(link.Options{URL: u, Credential: opt.Creds.Credential, UserAgent: "openvibe-node/" + opt.Version,
-- Log: opt.Log, HeartbeatInterval: opt.HeartbeatInterval, BackoffMin: opt.LinkBackoffMin}, n)
++ lo := link.Options{URL: u, Credential: opt.Creds.Credential, UserAgent: "openvibe-node/" + opt.Version,
++ Log: opt.Log, HeartbeatInterval: opt.HeartbeatInterval, BackoffMin: opt.LinkBackoffMin, ReauthInterval: opt.ReauthInterval}
++ if opt.Creds.NetworkPaired() {
++ // A Network-paired machine authenticates with node tokens, never a Bot credential.
++ lo.Credential = credentials.Secret{}
++ if opt.Tokens != nil {
++ lo.Tokens = opt.Tokens
++ } else {
++ lo.Tokens = link.NewTokenSource(opt.Creds, nil, lo.UserAgent)
++ }
++ }
++ n.link = link.New(lo, n)
// The WHIP endpoint is the pairing's whip_url (built by Bot from the publish key) unless the config sets one.
whip := opt.Config.Video.WHIPURL
if whip == "" {
@@@ -168,8 +182,12 @@ func New(opt Options) (*Node, error) {
return n, nil
}
--// Run runs until ctx ends, then stops every actuator and waits for the plugins to exit.
--func (n *Node) Run(ctx context.Context) error {
++// Run runs until ctx ends, then stops every actuator and waits for the plugins to exit. It ends on its own, with
++// link.ErrNodeRevoked, when OpenVibe.Network refuses the machine's node credential.
++func (n *Node) Run(parent context.Context) error {
++ ctx, cancel := context.WithCancel(parent)
++ defer cancel()
++ var fatal error
n.ctx = ctx
n.started = time.Now()
ctlErr := make(chan error, 1)
@@@ -196,7 +214,13 @@ func (n *Node) Run(ctx context.Context) error {
go func() { defer wg.Done(); n.startVideo(ctx) }()
if n.link != nil {
wg.Add(1)
-- go func() { defer wg.Done(); n.link.Run(ctx) }()
++ go func() {
++ defer wg.Done()
++ if err := n.link.Run(ctx); err != nil {
++ fatal = err
++ cancel()
++ }
++ }()
} else {
n.log.Warn("not paired: running local only (run `openvibe-node pair <CODE>`)")
}
@@@ -209,7 +233,7 @@ func (n *Node) Run(ctx context.Context) error {
cancelPlugins()
n.mgr.Wait()
wg.Wait()
-- return nil
++ return fatal
}
// ---- link.Handler ----
@@@ -718,6 +742,7 @@ func strSlice(v any) []string {
type Status struct {
Version string `json:"version"`
DeviceID string `json:"device_id,omitempty"`
++ Principal string `json:"principal,omitempty"` // nod_… of a Network-paired machine
Server string `json:"server,omitempty"`
Paired bool `json:"paired"`
Uptime string `json:"uptime"`
@@@ -762,7 +787,7 @@ func (n *Node) Status() Status {
s := Status{Version: n.opt.Version, Latch: n.latch.State(), ConfigDir: n.opt.Paths.ConfigDir, StateDir: n.opt.Paths.StateDir,
Uptime: time.Since(n.started).Round(time.Second).String()}
if n.opt.Creds != nil {
-- s.Paired, s.DeviceID, s.Server = true, n.opt.Creds.DeviceID, n.opt.Creds.Server
++ s.Paired, s.DeviceID, s.Principal, s.Server = true, n.opt.Creds.DeviceID, n.opt.Creds.Principal, n.opt.Creds.Server
}
n.mu.Lock()
s.Limits = n.limits
ddiff --git a/internal/protocol/protocol.go b/internal/protocol/protocol.go
iindex ff25479..bda8671 100644
---- a/internal/protocol/protocol.go
++++ b/internal/protocol/protocol.go
@@@ -21,6 +21,14 @@ const Version = 1
const (
PairPath = "/api/v1/pair"
DevicePath = "/device"
++ // BindPath binds a Network-paired machine (its node token, audience openvibe.bot) to its device on Bot.
++ BindPath = "/api/v1/devices/bind"
++)
++
++// OpenVibe.Network paths, relative to the Network origin (server/registry/node-principals.js).
++const (
++ NodePairingPath = "/api/v1/node-pairing" // redeems a Network pairing code for a node principal and its credential
++ TokenPath = "/oauth/token" // client_credentials: the node credential buys a node token
)
// Server → device message types.
@@@ -53,6 +61,7 @@ const (
TypeAck = "ack"
TypeNack = "nack"
TypeHeartbeat = "heartbeat"
++ TypeReauth = "reauth" // a Network-paired machine's fresh node token, before the last one expires
TypeEstopState = "estop_state"
)
@@@ -482,6 +491,12 @@ type Heartbeat struct {
RTTMS *int64 `json:"rtt_ms,omitempty"`
}
++// Reauth carries a fresh node token for the same principal; Bot closes the socket with 4002 when none arrives within
++// 330 s of the last one. Only a Network-paired machine sends it.
++type Reauth struct {
++ Token string `json:"token"`
++}
++
type EstopState struct {
Latched bool `json:"latched"`
By string `json:"by,omitempty"`
@@@ -502,6 +517,7 @@ func (Telemetry) MessageType() string { return TypeTelemetry }
func (Ack) MessageType() string { return TypeAck }
func (Nack) MessageType() string { return TypeNack }
func (Heartbeat) MessageType() string { return TypeHeartbeat }
++func (Reauth) MessageType() string { return TypeReauth }
func (EstopState) MessageType() string { return TypeEstopState }
func (JobRequest) MessageType() string { return TypeJob }
func (JobCancel) MessageType() string { return TypeJobCancel }
@@@ -523,6 +539,7 @@ var registry = map[string]func() Message{
TypeAck: func() Message { return &Ack{} },
TypeNack: func() Message { return &Nack{} },
TypeHeartbeat: func() Message { return &Heartbeat{} },
++ TypeReauth: func() Message { return &Reauth{} },
TypeEstopState: func() Message { return &EstopState{} },
TypeJob: func() Message { return &JobRequest{} },
TypeJobCancel: func() Message { return &JobCancel{} },
@@@ -607,6 +624,8 @@ func derefMessage(m Message) Message {
return *v
case *Heartbeat:
return *v
++ case *Reauth:
++ return *v
case *EstopState:
return *v
case *JobRequest:
@@@ -676,7 +695,8 @@ type ICEServer struct {
}
// PairResponse is the 201 answer of POST /api/v1/pair. The credential, publish key and WHIP URL (which embeds the
--// publish key; null when Bot has no ingest configured) appear only here.
++// publish key; null when Bot has no ingest configured) appear only here. POST /api/v1/devices/bind answers the same
++// body without the credential.
type PairResponse struct {
DeviceID string `json:"device_id"`
Credential string `json:"credential"`
@@@ -686,6 +706,43 @@ type PairResponse struct {
Profile json.RawMessage `json:"profile,omitempty"`
}
++// NodePairingRequest is the body of POST <network>/api/v1/node-pairing: the code is the credential. Pairing is the
++// pair_… id from the installer command; with it a wrong try counts against that pairing's code.
++type NodePairingRequest struct {
++ Code string `json:"code"`
++ Pairing string `json:"pairing,omitempty"`
++ Name string `json:"name,omitempty"`
++}
++
++// PairedFor is the service and the reference there (Bot: the robot id) a node principal was paired for.
++type PairedFor struct {
++ Service string `json:"service"`
++ Ref string `json:"ref,omitempty"`
++}
++
++// NodePairingResponse is the 201 answer of POST /api/v1/node-pairing. The credential appears only here.
++type NodePairingResponse struct {
++ Principal string `json:"principal"` // nod_…
++ NodeID string `json:"node_id"`
++ HomeCell string `json:"home_cell"`
++ Credential string `json:"credential"`
++ TokenEndpoint string `json:"token_endpoint,omitempty"`
++ PairedFor *PairedFor `json:"paired_for"`
++}
++
++// TokenResponse is the 200 answer of POST <network>/oauth/token.
++type TokenResponse struct {
++ AccessToken string `json:"access_token"`
++ TokenType string `json:"token_type"`
++ ExpiresIn int `json:"expires_in"` // seconds (300 for a node token)
++}
++
++// OAuthError is the OAuth-shaped refusal of /oauth/token (invalid_client for a wrong or revoked credential).
++type OAuthError struct {
++ Error string `json:"error"`
++ Description string `json:"error_description,omitempty"`
++}
++
// Problem is the RFC 9457 body of a non-2xx answer from Bot's REST API.
type Problem struct {
Status int `json:"status"`
ddiff --git a/internal/protocol/protocol_test.go b/internal/protocol/protocol_test.go
iindex b4811c3..b1df25a 100644
---- a/internal/protocol/protocol_test.go
++++ b/internal/protocol/protocol_test.go
@@@ -21,6 +21,7 @@ func TestEncodeDecodeRoundTrip(t *testing.T) {
Ack{ID: "c1"},
Nack{ID: "c1", FaultCode: FaultEstopped},
Heartbeat{Seq: 10},
++ Reauth{Token: "eyJ.node.token"},
EstopState{Latched: true},
Error{Code: ErrNotPaired},
}
ddiff --git a/internal/service/service.go b/internal/service/service.go
iindex 763897e..928769e 100644
---- a/internal/service/service.go
++++ b/internal/service/service.go
@@@ -65,6 +65,10 @@ func (p *program) Stop(ks.Service) error {
return nil
}
++// ExitPairAgain is the exit status of a Node whose node credential OpenVibe.Network refused (EX_CONFIG): the
++// systemd unit does not restart it (RestartPreventExitStatus), since only pairing again can help.
++const ExitPairAgain = 78
++
// Options for the service definition.
type Options struct {
// Arguments passed to the executable by the service manager (["run"] plus --home when set).
@@@ -116,6 +120,7 @@ StandardError=file:{{LogDirectory}}/{{Name}}.err
{{end}}{{if Restart}}Restart={{Restart}}
{{end}}{{if SuccessExitStatus}}SuccessExitStatus={{SuccessExitStatus}}
{{end}}RestartSec=120
++RestartPreventExitStatus=78
Delegate=yes
EnvironmentFile=-/etc/sysconfig/{{Name}}
ddiff --git a/internal/service/service_test.go b/internal/service/service_test.go
iindex 5539fc9..1de312e 100644
---- a/internal/service/service_test.go
++++ b/internal/service/service_test.go
@@@ -2,6 +2,7 @@ package service
import (
"context"
++ "fmt"
"strings"
"testing"
"time"
@@@ -36,4 +37,8 @@ func TestConfig(t *testing.T) {
if u, _ := c.Option["SystemdScript"].(string); !strings.Contains(u, "\n[Service]\n") || !strings.Contains(u, "\nDelegate=yes\n") {
t.Fatalf("the systemd unit must delegate the Node's cgroup: %q", u)
}
++ // A revoked node credential exits with ExitPairAgain: systemd must not restart it into the same refusal.
++ if u, _ := c.Option["SystemdScript"].(string); !strings.Contains(u, fmt.Sprintf("\nRestartPreventExitStatus=%d\n", ExitPairAgain)) {
++ t.Fatalf("the systemd unit must not restart a Node that has to pair again: %q", u)
++ }
}
ddiff --git a/scripts/test-install-pair-args.sh b/scripts/test-install-pair-args.sh
nnew file mode 100644
iindex 0000000..180a24e
---- /dev/null
++++ b/scripts/test-install-pair-args.sh
@@@ -0,0 +1,43 @@
++#!/bin/sh
++# Tests install.sh's flag parsing into the `openvibe-node pair` command: the legacy Bot form (--robot rob_… --code)
++# unchanged, and the Network form (--network URL --pairing pair_… --code). OPENVIBE_INSTALL_PAIR_ARGS_ONLY=1 makes
++# the installer print the arguments and exit before it downloads, installs or pairs anything.
++set -eu
++
++here="$(cd "$(dirname "$0")" && pwd)"
++installer="$here/../install/install.sh"
++
++fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; }
++
++# args <installer flags…>: the pair arguments, space-joined.
++args() { OPENVIBE_INSTALL_PAIR_ARGS_ONLY=1 sh "$installer" "$@" | tr '\n' ' ' | sed 's/ $//'; }
++
++expect() {
++ want="$1"
++ shift
++ got="$(args "$@")" || fail "install.sh $* exited non-zero"
++ [ "$got" = "$want" ] || fail "install.sh $*: got '$got', want '$want'"
++}
++
++refuses() {
++ if OPENVIBE_INSTALL_PAIR_ARGS_ONLY=1 sh "$installer" "$@" >/dev/null 2>&1; then fail "install.sh $* was accepted"; fi
++}
++
++robot=rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X
++pairing=pair_01J8Z4M2Q0R7T9YV3K6N8P1W2Y
++
++expect "pair ABCD-1234 --robot $robot" --robot "$robot" --code ABCD-1234 --driver adeept
++expect "pair ABCD-1234 --robot $robot --name Rover" --robot="$robot" --code=ABCD-1234 --name Rover
++expect "pair ABCD-1234" ABCD-1234
++expect "pair ABCD-1234 --network https://openvibe.network --pairing $pairing" \
++ --network https://openvibe.network --pairing "$pairing" --code ABCD-1234 --driver adeept
++expect "pair ABCD-1234 --network https://openvibe.network --pairing $pairing --name Rover" \
++ --network=https://openvibe.network --pairing="$pairing" --code=ABCD-1234 --name=Rover
++expect "pair ABCD-1234 --network https://openvibe.network" --network https://openvibe.network --pairing ABCD-1234
++
++refuses --pairing "$pairing" --code ABCD-1234
++refuses --network https://openvibe.network --pairing "$pairing"
++refuses --network http://openvibe.network --pairing "$pairing" --code ABCD-1234
++refuses --network https://openvibe.network --pairing nope --code ABCD-1234
++
++echo "install.sh pair arguments: ok"