openvibers/openvibe.link Commits
Pair through OpenVibe.Network with node tokens and bind on Bot
Changes
@@@ -97,5 +97,6 @@ jobs:runs-on: ubuntu-lateststeps:- 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@@@ -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, manualinstall, Windows and configuration: [docs/install.md](docs/install.md).```sh@@@ -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 stdinopenvibe-node run [--dry-run]openvibe-node install [--user NAME]@@@ -93,6 +95,8 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) introbot = 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) intif *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 stringforce 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>)")}nnew file mode 100644@@@ -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)++}@@@ -20,6 +20,22 @@ ports, works behind any home router), and a local kill switch stops everything inames 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 pluginbundle 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).```shsudo install -m 755 openvibe-node-linux-arm64 /usr/local/bin/openvibe-nodesudo 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-1234sudo openvibe-node install # systemd / launchd / Windows service, starts itopenvibe-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 theservice'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).@@@ -46,6 +46,48 @@ The credential and publish key are stored in `credential.json` (mode 0600, withappear 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 linkOne 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@@@ -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 -euREPO_URL="https://github.com/OpenVibers/OpenVibe.Node"@@@ -34,14 +38,17 @@ CODE=""DRIVER="none"ROBOT_ID=""NAME=""++NETWORK=""++PAIRING=""SERVICE=1LOCAL=""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 @@ donecase "$DRIVER" in adeept|adeept-mecanum|cozmo|none) ;; *) die "--driver must be adeept, adeept-mecanum, cozmo or none" ;; esaccase "$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" ]; thenif $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"fielsesay "not paired yet: get a code on openvibe.bot and run: sudo openvibe-node pair <CODE>"@@@ -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 credentialsimport (@@@ -45,22 +50,32 @@ func (s *Secret) UnmarshalJSON([]byte) error {type Credentials struct {DeviceID stringRobotID 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,@@@ -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")++ }++}@@@ -51,7 +51,9 @@ type Server struct {estop map[string]bool // robot id → the robot's e-stop latch, as Bot keeps itpairRequests []protocol.PairRequestpairRefusal *refusal++ bindRefusals []refusalcredInURL bool++ binds atomic.Int64conns chan *Conncount 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) {nnew file mode 100644@@@ -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})++}@@@ -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/deviceCredential 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.DialerHeartbeatInterval time.Duration // default 1 sDeadman 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 := 0for 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 nilcase <-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)nnew file mode 100644@@@ -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++ }++}nnew file mode 100644@@@ -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)++ }++ }++}@@@ -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)}nnew file mode 100644@@@ -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")++ }++}@@@ -43,7 +43,11 @@ type Options struct {Log *slog.LoggerVersion string++ // Tokens are a Network-paired machine's node tokens; nil builds them from Creds.++ Tokens link.Tokens++// For tests.++ ReauthInterval time.DurationHeartbeatInterval time.DurationLinkBackoffMin time.DurationVideoLoopback 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.WHIPURLif 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 errorn.ctx = ctxn.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 machineServer 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@@@ -21,6 +21,14 @@ const Version = 1const (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 expiresTypeEstopState = "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 *vcase *Heartbeat:return *v++ case *Reauth:++ return *vcase *EstopState:return *vcase *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"`@@@ -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},}@@@ -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=78Delegate=yesEnvironmentFile=-/etc/sysconfig/{{Name}}@@@ -2,6 +2,7 @@ package serviceimport ("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)++ }}nnew file mode 100644@@@ -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"
@@ -97,5 +97,6 @@ jobs:
@@ -97,5 +97,6 @@ jobs:
runs-on: ubuntu-latest
runs-on: ubuntu-latest
steps:
steps:
- uses: actions/checkout@v4
- 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-stock-server.sh
+ - run: sh scripts/test-install-pair-args.sh
@@ -9,7 +9,8 @@ curl -fsSL https://openvibe.bot/install | sh -s -- --robot rob_… --code ABCD-1
@@ -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`
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).
install, Windows and configuration: [docs/install.md](docs/install.md).
```sh
```sh
@@ -1,6 +1,7 @@
@@ -1,6 +1,7 @@
// Command openvibe-node connects a computer, server, Raspberry Pi or robot to OpenVibe.
// 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
// 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 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 run run in the foreground (this is also what the service runs)
// openvibe-node install install and start the system service
// openvibe-node install install and start the system service
@@ -45,6 +46,7 @@ const usage = `openvibe-node %s: connect this device to OpenVibe.
@@ -45,6 +46,7 @@ const usage = `openvibe-node %s: connect this device to OpenVibe.
Usage:
Usage:
openvibe-node pair <CODE> [--robot rob_…] [--name NAME] [--server URL] [--kind onboard|bridge] [--force]
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 credential import store the credential from a rotate response, read as JSON on stdin
openvibe-node run [--dry-run]
openvibe-node run [--dry-run]
openvibe-node install [--user NAME]
openvibe-node install [--user NAME]
@@ -93,6 +95,8 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
@@ -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)")
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)")
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)")
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")
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)")
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)")
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
@@ -119,11 +123,21 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
if *codeArg != "" {
if *codeArg != "" {
positional = append(positional, *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 {
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
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":
case "credential":
if len(positional) != 1 || positional[0] != "import" {
if len(positional) != 1 || positional[0] != "import" {
fmt.Fprintln(stderr, "usage: openvibe-node credential import (reads the rotate response JSON on stdin)")
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
@@ -159,6 +173,9 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
}
}
if err != nil {
if err != nil {
fmt.Fprintln(stderr, "error:", err)
fmt.Fprintln(stderr, "error:", err)
+ if errors.Is(err, link.ErrNodeRevoked) {
+ return service.ExitPairAgain
+ }
return 1
return 1
}
}
return 0
return 0
@@ -212,46 +229,78 @@ func probeAll(cfg *config.Config, paths config.Paths, log *slog.Logger) (map[str
@@ -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`.
// pairArgs are the flags of `openvibe-node pair`.
type pairArgs struct {
type pairArgs struct {
code, robot, name, server, kind string
code, robot, name, server, kind string
+ network, pairing string
force bool
force bool
}
}
// maxNameLen is the longest device name OpenVibe.Bot stores.
// maxNameLen is the longest device name OpenVibe.Bot stores.
const maxNameLen = 80
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 == "" {
if name == "" {
name, _ = os.Hostname()
name, _ = os.Hostname()
}
}
if r := []rune(name); len(r) > maxNameLen {
if r := []rune(name); len(r) > maxNameLen {
name = string(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 {
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)
cfg, err := loadConfig(g)
if err != nil {
if err != nil {
- return err
+ return nil, false, err
}
}
_, statErr := os.Stat(g.paths.ConfigFile())
_, 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 {
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
return err
}
}
descs, _ := probeAll(cfg, g.paths, g.log)
descs, _ := probeAll(cfg, g.paths, g.log)
@@ -276,21 +325,92 @@ func cmdPair(g *globals, a pairArgs, stdout io.Writer) error {
@@ -276,21 +325,92 @@ func cmdPair(g *globals, a pairArgs, stdout io.Writer) error {
return err
return err
}
}
chownLikeDir(g.paths.CredentialFile(), g.paths.ConfigDir)
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
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
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.
// maxRotateJSON caps what `credential import` reads from stdin: Bot's rotate answer is a few hundred bytes.
const maxRotateJSON = 64 << 10
const maxRotateJSON = 64 << 10
@@ -329,6 +449,9 @@ func cmdCredentialImport(g *globals, stdin io.Reader, stdout io.Writer) error {
@@ -329,6 +449,9 @@ func cmdCredentialImport(g *globals, stdin io.Reader, stdout io.Writer) error {
if err != nil {
if err != nil {
return err
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 {
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)
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 {
@@ -368,7 +491,15 @@ func cmdRun(g *globals, dryRun bool) error {
if err != nil && !errors.Is(err, credentials.ErrNotPaired) {
if err != nil && !errors.Is(err, credentials.ErrNotPaired) {
return err
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 {
if err != nil {
return err
return err
}
}
@@ -454,7 +585,7 @@ func cmdStatus(g *globals, asJSON bool, stdout io.Writer) error {
@@ -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, "")))
fmt.Fprintf(stdout, "OpenVibe Node %s, running for %s (service: %s)\n", s.Version, s.Uptime, service.Status(serviceOptions(g, "")))
if s.Paired {
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 {
} else {
fmt.Fprintln(stdout, "Device: not paired (openvibe-node pair <CODE>)")
fmt.Fprintln(stdout, "Device: not paired (openvibe-node pair <CODE>)")
}
}
@@ -498,6 +629,18 @@ func cmdStatus(g *globals, asJSON bool, stdout io.Writer) error {
@@ -498,6 +629,18 @@ func cmdStatus(g *globals, asJSON bool, stdout io.Writer) error {
return nil
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 {
func latchText(l safety.LatchState) string {
switch {
switch {
case l.Local && l.Remote:
case l.Local && l.Remote:
@@ -512,17 +655,18 @@ func latchText(l safety.LatchState) string {
@@ -512,17 +655,18 @@ func latchText(l safety.LatchState) string {
func offlineStatus(g *globals, asJSON bool, stdout io.Writer) error {
func offlineStatus(g *globals, asJSON bool, stdout io.Writer) error {
type offline struct {
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}
o := offline{Service: service.Status(serviceOptions(g, "")), Config: g.paths.ConfigDir}
if c, err := credentials.Load(g.paths.CredentialFile(), nil); err == nil {
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 {
if l, _ := safety.OpenLatch(g.paths.LatchFile()); l != nil {
o.Latch = l.State()
o.Latch = l.State()
@@ -532,7 +676,7 @@ func offlineStatus(g *globals, asJSON bool, stdout io.Writer) error {
@@ -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)
fmt.Fprintf(stdout, "OpenVibe Node %s is not running (service: %s).\n", version, o.Service)
if o.Paired {
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 {
} else {
fmt.Fprintln(stdout, "Device: not paired (openvibe-node pair <CODE>)")
fmt.Fprintln(stdout, "Device: not paired (openvibe-node pair <CODE>)")
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,148 @@
@@ -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)
+}
@@ -20,6 +20,22 @@ ports, works behind any home router), and a local kill switch stops everything i
@@ -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).
names the device on openvibe.bot (default: the hostname).
3. Confirm the new device on openvibe.bot.
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
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
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
`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).
@@ -37,6 +53,8 @@ from plugins that send JPEG frames).
```sh
```sh
sudo install -m 755 openvibe-node-linux-arm64 /usr/local/bin/openvibe-node
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)
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
sudo openvibe-node install # systemd / launchd / Windows service, starts it
openvibe-node status
openvibe-node status
```
```
@@ -57,7 +75,7 @@ need Python; set `"python"` in `config.json`.
@@ -57,7 +75,7 @@ need Python; set `"python"` in `config.json`.
| command | what it does |
| 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 run [--dry-run]` | run in the foreground (what the service runs) |
| `openvibe-node install [--user NAME]` | install and start the service |
| `openvibe-node install [--user NAME]` | install and start the service |
| `openvibe-node uninstall` | stop and remove the service (config and credential are kept) |
| `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
@@ -74,7 +92,7 @@ obeys within a quarter second and a starting Node obeys before anything moves. T
| Linux | macOS / Windows | contents |
| Linux | macOS / Windows | contents |
|---------------------------------------|-------------------------------------------------------|-----------------------------------|
|---------------------------------------|-------------------------------------------------------|-----------------------------------|
| `/etc/openvibe-node/config.json` | `…/OpenVibe Node/config.json` | configuration |
| `/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/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/node.sock` | `…/OpenVibe Node/node.sock` | local control socket (mode 600) |
| `/var/lib/openvibe-node/venv` | `…/OpenVibe Node/venv` | plugin Python environment |
| `/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
@@ -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:
- `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>`.
`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
- 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.
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).
- A plugin in `faulted` state says why in `status` (missing library, not a Raspberry Pi, robot not reachable, firmware).
@@ -46,6 +46,48 @@ The credential and publish key are stored in `credential.json` (mode 0600, with
@@ -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
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).
`[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
## 2. The control link
One WebSocket per device, opened by the device:
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
@@ -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" |
| `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` |
| `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.
`bot.not_ready`, `bot.forbidden`, …) are logged and never end the connection.
### Rotating a credential
### Rotating a credential
@@ -126,6 +174,7 @@ applied the Node runs nothing but `halt` (`nack not_ready`), so that latch is in
@@ -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) |
| `ack` | `id`; Node extra: `latency_ms` (receipt → plugin ack) |
| `nack` | `id`, `fault_code`; Node extra: `message` |
| `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) |
| `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` |
| `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
The first `status` and `estop_state` go out once the connection's `config` has been applied, never right after the
@@ -2,16 +2,19 @@
@@ -2,16 +2,19 @@
# OpenVibe Node installer.
# 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 -- --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
# 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,
# 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.
# 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:
# Options:
# --robot ROBOT the rob_… id of the robot to pair with (from openvibe.bot). A driver kind here (adeept, …) is the
# --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.
# old meaning of --robot: still accepted, but deprecated; use --driver.
# --code CODE the one-time pairing code (or give it as the first argument).
# --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).
# --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.
# --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.
# --no-service install and pair, but do not install the service.
@@ -27,6 +30,7 @@
@@ -27,6 +30,7 @@
# OPENVIBE_CRONTAB_ROOT=FILE edit FILE as root's crontab instead of the real one.
# 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_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_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
set -eu
REPO_URL="https://github.com/OpenVibers/OpenVibe.Node"
REPO_URL="https://github.com/OpenVibers/OpenVibe.Node"
@@ -34,14 +38,17 @@ CODE=""
@@ -34,14 +38,17 @@ CODE=""
DRIVER="none"
DRIVER="none"
ROBOT_ID=""
ROBOT_ID=""
NAME=""
NAME=""
+NETWORK=""
+PAIRING=""
SERVICE=1
SERVICE=1
LOCAL=""
LOCAL=""
TAG="latest"
TAG="latest"
usage() {
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 -- --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: $*"; }
say() { printf '%s\n' "openvibe-node: $*"; }
die() { printf '%s\n' "openvibe-node: error: $*" >&2; exit 1; }
die() { printf '%s\n' "openvibe-node: error: $*" >&2; exit 1; }
@@ -63,6 +70,10 @@ while [ $# -gt 0 ]; do
@@ -63,6 +70,10 @@ while [ $# -gt 0 ]; do
--code=*) CODE="${1#*=}"; shift ;;
--code=*) CODE="${1#*=}"; shift ;;
--name) NAME="${2:-}"; shift 2 ;;
--name) NAME="${2:-}"; shift 2 ;;
--name=*) NAME="${1#*=}"; shift ;;
--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 ;;
--no-service) SERVICE=0; shift ;;
--local) LOCAL="${2:-}"; shift 2 ;;
--local) LOCAL="${2:-}"; shift 2 ;;
--version) TAG="${2:-}"; shift 2 ;;
--version) TAG="${2:-}"; shift 2 ;;
@@ -74,6 +85,29 @@ done
@@ -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 "$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
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.
# Root for the service, /usr/local/bin and /etc.
SUDO=""
SUDO=""
@@ -315,12 +349,9 @@ fi
@@ -315,12 +349,9 @@ fi
# ---- pair ----
# ---- pair ----
if [ -n "$CODE" ]; then
if [ -n "$CODE" ]; then
if $SUDO "$BIN_DIR/openvibe-node" status --json 2>/dev/null | grep -q '"paired":true'; 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
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
fi
else
else
say "not paired yet: get a code on openvibe.bot and run: sudo openvibe-node pair <CODE>"
say "not paired yet: get a code on openvibe.bot and run: sudo openvibe-node pair <CODE>"
@@ -1,6 +1,11 @@
@@ -1,6 +1,11 @@
// Package credentials stores the device credential and publish key the pairing returned. The file is mode 0600 and
// 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,
// 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.
// 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
package credentials
import (
import (
@@ -45,22 +50,32 @@ func (s *Secret) UnmarshalJSON([]byte) error {
@@ -45,22 +50,32 @@ func (s *Secret) UnmarshalJSON([]byte) error {
type Credentials struct {
type Credentials struct {
DeviceID string
DeviceID string
RobotID string // rob_… the device was paired to
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.
// file is the on-disk form; only Save and Load see plain secrets.
type file struct {
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"`
RobotID string `json:"robot_id,omitempty"`
- Credential string `json:"credential"`
+ Credential string `json:"credential,omitempty"`
PublishKey string `json:"publish_key"`
PublishKey string `json:"publish_key"`
Server string `json:"server"`
Server string `json:"server"`
DeviceURL string `json:"device_url,omitempty"`
DeviceURL string `json:"device_url,omitempty"`
@@ -70,16 +85,41 @@ type file struct {
@@ -70,16 +85,41 @@ type file struct {
PairedAt time.Time `json:"paired_at"`
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.
// 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>`")
var ErrNotPaired = errors.New("this device is not paired; run `openvibe-node pair <CODE>`")
// Save writes the credentials atomically with mode 0600.
// Save writes the credentials atomically with mode 0600.
func Save(path string, c *Credentials) error {
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")
return errors.New("credentials: refusing to save an empty credential")
}
}
b, err := json.MarshalIndent(file{
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,
Server: c.Server, DeviceURL: c.DeviceURL, WHIPURL: c.WHIPURL, ICEServers: c.ICEServers, Profile: c.Profile,
PairedAt: c.PairedAt,
PairedAt: c.PairedAt,
}, "", " ")
}, "", " ")
@@ -142,10 +182,11 @@ func Load(path string, warn func(string)) (*Credentials, error) {
@@ -142,10 +182,11 @@ func Load(path string, warn func(string)) (*Credentials, error) {
// Never wrap the decoder error: it can quote file contents.
// Never wrap the decoder error: it can quote file contents.
return nil, fmt.Errorf("credentials: %s is not valid JSON", path)
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 nil, fmt.Errorf("credentials: %s is incomplete; pair again", path)
}
}
return &Credentials{
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),
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,
Server: f.Server, DeviceURL: f.DeviceURL, WHIPURL: f.WHIPURL, ICEServers: f.ICEServers, Profile: f.Profile,
PairedAt: f.PairedAt,
PairedAt: f.PairedAt,
@@ -9,6 +9,8 @@ import (
@@ -9,6 +9,8 @@ import (
"runtime"
"runtime"
"strings"
"strings"
"testing"
"testing"
+
+ "github.com/OpenVibers/OpenVibe.Node/internal/protocol"
)
)
const secret = "s3cr3t-credential-value"
const secret = "s3cr3t-credential-value"
@@ -83,3 +85,46 @@ func TestCorruptFileErrorHasNoContents(t *testing.T) {
@@ -83,3 +85,46 @@ func TestCorruptFileErrorHasNoContents(t *testing.T) {
t.Fatalf("err %v", err)
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")
+ }
+}
@@ -51,7 +51,9 @@ type Server struct {
@@ -51,7 +51,9 @@ type Server struct {
estop map[string]bool // robot id → the robot's e-stop latch, as Bot keeps it
estop map[string]bool // robot id → the robot's e-stop latch, as Bot keeps it
pairRequests []protocol.PairRequest
pairRequests []protocol.PairRequest
pairRefusal *refusal
pairRefusal *refusal
+ bindRefusals []refusal
credInURL bool
credInURL bool
+ binds atomic.Int64
conns chan *Conn
conns chan *Conn
count atomic.Int64
count atomic.Int64
@@ -76,6 +78,7 @@ func New() *Server {
@@ -76,6 +78,7 @@ func New() *Server {
mux := http.NewServeMux()
mux := http.NewServeMux()
mux.HandleFunc(protocol.PairPath, s.pair)
mux.HandleFunc(protocol.PairPath, s.pair)
mux.HandleFunc(protocol.DevicePath, s.device)
mux.HandleFunc(protocol.DevicePath, s.device)
+ mux.HandleFunc(protocol.BindPath, s.bind)
mux.HandleFunc("/whip/", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/whip/", func(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
s.mu.Lock()
h := s.WHIP
h := s.WHIP
@@ -236,6 +239,49 @@ func (s *Server) pair(w http.ResponseWriter, r *http.Request) {
@@ -236,6 +239,49 @@ func (s *Server) pair(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(body)
_, _ = 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 }}
var upgrader = websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
func (s *Server) device(w http.ResponseWriter, r *http.Request) {
func (s *Server) device(w http.ResponseWriter, r *http.Request) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,173 @@
@@ -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})
+}
@@ -10,6 +10,10 @@
@@ -10,6 +10,10 @@
// 4002 (credential refused) and 4003 (revoked) keep retrying, never sooner than CredentialRetryMin, and log what to
// 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.
// 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
// 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.
// absolute deadline_ms of a command.
package link
package link
@@ -51,9 +55,12 @@ type Handler interface {
@@ -51,9 +55,12 @@ type Handler interface {
type Options struct {
type Options struct {
URL string // wss://openvibe.bot/device
URL string // wss://openvibe.bot/device
Credential credentials.Secret
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
HeartbeatInterval time.Duration // default 1 s
Deadman time.Duration // default 2 × heartbeat
Deadman time.Duration // default 2 × heartbeat
@@ -61,6 +68,12 @@ type Options struct {
@@ -61,6 +68,12 @@ type Options struct {
BackoffMax time.Duration // default 30 s
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
// 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.
// the next try; jitter only ever adds to it.
const CredentialRetryMin = 10 * time.Second
const CredentialRetryMin = 10 * time.Second
@@ -115,6 +128,9 @@ func New(opt Options, h Handler) *Link {
@@ -115,6 +128,9 @@ func New(opt Options, h Handler) *Link {
if opt.BackoffMax <= 0 {
if opt.BackoffMax <= 0 {
opt.BackoffMax = 30 * time.Second
opt.BackoffMax = 30 * time.Second
}
}
+ if opt.ReauthInterval <= 0 {
+ opt.ReauthInterval = 240 * time.Second
+ }
if opt.Log == nil {
if opt.Log == nil {
opt.Log = slog.Default()
opt.Log = slog.Default()
}
}
@@ -236,18 +252,27 @@ func (l *Link) Send(m protocol.Message) error {
@@ -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
attempt := 0
for ctx.Err() == nil {
for ctx.Err() == nil {
start := time.Now()
start := time.Now()
err := l.session(ctx)
err := l.session(ctx)
if ctx.Err() != nil {
if ctx.Err() != nil {
- return
+ return nil
}
}
msg := errMsg(err)
msg := errMsg(err)
l.lastErr.Store(msg)
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 {
if time.Since(start) > 30*time.Second {
attempt = 0
attempt = 0
}
}
@@ -261,10 +286,11 @@ func (l *Link) Run(ctx context.Context) {
@@ -261,10 +286,11 @@ func (l *Link) Run(ctx context.Context) {
}
}
select {
select {
case <-ctx.Done():
case <-ctx.Done():
- return
+ return nil
case <-time.After(wait):
case <-time.After(wait):
}
}
}
}
+ return nil
}
}
// backoff is the wait before reconnect attempt number attempt (0 first) after err: exponential with full jitter and
// 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 {
@@ -319,8 +345,16 @@ func errMsg(err error) string {
}
}
func (l *Link) session(ctx context.Context) error {
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 := http.Header{}
- h.Set("Authorization", "Bearer "+l.opt.Credential.Reveal())
+ h.Set("Authorization", "Bearer "+cred.Reveal())
if l.opt.UserAgent != "" {
if l.opt.UserAgent != "" {
h.Set("User-Agent", l.opt.UserAgent)
h.Set("User-Agent", l.opt.UserAgent)
}
}
@@ -332,7 +366,7 @@ func (l *Link) session(ctx context.Context) error {
@@ -332,7 +366,7 @@ func (l *Link) session(ctx context.Context) error {
if resp != nil {
if resp != nil {
return fmt.Errorf("connect %s: HTTP %d", redactURL(l.opt.URL), resp.StatusCode)
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()
defer conn.Close()
conn.SetReadLimit(1 << 20)
conn.SetReadLimit(1 << 20)
@@ -424,6 +458,10 @@ func (l *Link) session(ctx context.Context) error {
@@ -424,6 +458,10 @@ func (l *Link) session(ctx context.Context) error {
}
}
}()
}()
+ if l.opt.Tokens != nil {
+ go l.reauth(sessCtx, &up, end)
+ }
+
for {
for {
_, data, err := conn.ReadMessage()
_, data, err := conn.ReadMessage()
if err != nil {
if err != nil {
@@ -431,7 +469,7 @@ func (l *Link) session(ctx context.Context) error {
@@ -431,7 +469,7 @@ func (l *Link) session(ctx context.Context) error {
if ce := closeErr(err); ce != nil {
if ce := closeErr(err); ce != nil {
end(ce)
end(ce)
} else {
} else {
- end(fmt.Errorf("read: %s", scrub(err.Error(), l.opt.Credential)))
+ end(fmt.Errorf("read: %s", scrub(err.Error(), cred)))
}
}
}
}
return endErr
return endErr
@@ -510,6 +548,41 @@ func (l *Link) session(ctx context.Context) error {
@@ -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.
// DeviceURL derives wss://host/device from the server origin.
func DeviceURL(server string) (string, error) {
func DeviceURL(server string) (string, error) {
u, err := url.Parse(server)
u, err := url.Parse(server)
new file mode 100644
new file mode 100644
@@ -0,0 +1,273 @@
@@ -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
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,182 @@
@@ -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)
+ }
+ }
+}
@@ -8,7 +8,6 @@ import (
@@ -8,7 +8,6 @@ import (
"fmt"
"fmt"
"io"
"io"
"net/http"
"net/http"
- "net/url"
"regexp"
"regexp"
"strings"
"strings"
"time"
"time"
@@ -46,12 +45,8 @@ var pairMessages = map[string]string{
@@ -46,12 +45,8 @@ var pairMessages = map[string]string{
// Pair redeems a one-time code with POST <server>/api/v1/pair.
// 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) {
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)
body, err := json.Marshal(req)
if err != nil {
if err != nil {
@@ -64,10 +59,7 @@ func Pair(ctx context.Context, client *http.Client, server string, req protocol.
@@ -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("Content-Type", "application/json")
hreq.Header.Set("User-Agent", "openvibe-node/"+req.AgentVersion)
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 {
if err != nil {
return nil, fmt.Errorf("pairing request failed: %w", err)
return nil, fmt.Errorf("pairing request failed: %w", err)
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -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")
+ }
+}
@@ -43,7 +43,11 @@ type Options struct {
@@ -43,7 +43,11 @@ type Options struct {
Log *slog.Logger
Log *slog.Logger
Version string
Version string
+ // Tokens are a Network-paired machine's node tokens; nil builds them from Creds.
+ Tokens link.Tokens
+
// For tests.
// For tests.
+ ReauthInterval time.Duration
HeartbeatInterval time.Duration
HeartbeatInterval time.Duration
LinkBackoffMin time.Duration
LinkBackoffMin time.Duration
VideoLoopback bool
VideoLoopback bool
@@ -133,8 +137,18 @@ func New(opt Options) (*Node, error) {
@@ -133,8 +137,18 @@ func New(opt Options) (*Node, error) {
return nil, err
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.
// 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
whip := opt.Config.Video.WHIPURL
if whip == "" {
if whip == "" {
@@ -168,8 +182,12 @@ func New(opt Options) (*Node, error) {
@@ -168,8 +182,12 @@ func New(opt Options) (*Node, error) {
return n, nil
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.ctx = ctx
n.started = time.Now()
n.started = time.Now()
ctlErr := make(chan error, 1)
ctlErr := make(chan error, 1)
@@ -196,7 +214,13 @@ func (n *Node) Run(ctx context.Context) error {
@@ -196,7 +214,13 @@ func (n *Node) Run(ctx context.Context) error {
go func() { defer wg.Done(); n.startVideo(ctx) }()
go func() { defer wg.Done(); n.startVideo(ctx) }()
if n.link != nil {
if n.link != nil {
wg.Add(1)
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 {
} else {
n.log.Warn("not paired: running local only (run `openvibe-node pair <CODE>`)")
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 {
@@ -209,7 +233,7 @@ func (n *Node) Run(ctx context.Context) error {
cancelPlugins()
cancelPlugins()
n.mgr.Wait()
n.mgr.Wait()
wg.Wait()
wg.Wait()
- return nil
+ return fatal
}
}
// ---- link.Handler ----
// ---- link.Handler ----
@@ -718,6 +742,7 @@ func strSlice(v any) []string {
@@ -718,6 +742,7 @@ func strSlice(v any) []string {
type Status struct {
type Status struct {
Version string `json:"version"`
Version string `json:"version"`
DeviceID string `json:"device_id,omitempty"`
DeviceID string `json:"device_id,omitempty"`
+ Principal string `json:"principal,omitempty"` // nod_… of a Network-paired machine
Server string `json:"server,omitempty"`
Server string `json:"server,omitempty"`
Paired bool `json:"paired"`
Paired bool `json:"paired"`
Uptime string `json:"uptime"`
Uptime string `json:"uptime"`
@@ -762,7 +787,7 @@ func (n *Node) Status() Status {
@@ -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,
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()}
Uptime: time.Since(n.started).Round(time.Second).String()}
if n.opt.Creds != nil {
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()
n.mu.Lock()
s.Limits = n.limits
s.Limits = n.limits
@@ -21,6 +21,14 @@ const Version = 1
@@ -21,6 +21,14 @@ const Version = 1
const (
const (
PairPath = "/api/v1/pair"
PairPath = "/api/v1/pair"
DevicePath = "/device"
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.
// Server → device message types.
@@ -53,6 +61,7 @@ const (
@@ -53,6 +61,7 @@ const (
TypeAck = "ack"
TypeAck = "ack"
TypeNack = "nack"
TypeNack = "nack"
TypeHeartbeat = "heartbeat"
TypeHeartbeat = "heartbeat"
+ TypeReauth = "reauth" // a Network-paired machine's fresh node token, before the last one expires
TypeEstopState = "estop_state"
TypeEstopState = "estop_state"
)
)
@@ -482,6 +491,12 @@ type Heartbeat struct {
@@ -482,6 +491,12 @@ type Heartbeat struct {
RTTMS *int64 `json:"rtt_ms,omitempty"`
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 {
type EstopState struct {
Latched bool `json:"latched"`
Latched bool `json:"latched"`
By string `json:"by,omitempty"`
By string `json:"by,omitempty"`
@@ -502,6 +517,7 @@ func (Telemetry) MessageType() string { return TypeTelemetry }
@@ -502,6 +517,7 @@ func (Telemetry) MessageType() string { return TypeTelemetry }
func (Ack) MessageType() string { return TypeAck }
func (Ack) MessageType() string { return TypeAck }
func (Nack) MessageType() string { return TypeNack }
func (Nack) MessageType() string { return TypeNack }
func (Heartbeat) MessageType() string { return TypeHeartbeat }
func (Heartbeat) MessageType() string { return TypeHeartbeat }
+func (Reauth) MessageType() string { return TypeReauth }
func (EstopState) MessageType() string { return TypeEstopState }
func (EstopState) MessageType() string { return TypeEstopState }
func (JobRequest) MessageType() string { return TypeJob }
func (JobRequest) MessageType() string { return TypeJob }
func (JobCancel) MessageType() string { return TypeJobCancel }
func (JobCancel) MessageType() string { return TypeJobCancel }
@@ -523,6 +539,7 @@ var registry = map[string]func() Message{
@@ -523,6 +539,7 @@ var registry = map[string]func() Message{
TypeAck: func() Message { return &Ack{} },
TypeAck: func() Message { return &Ack{} },
TypeNack: func() Message { return &Nack{} },
TypeNack: func() Message { return &Nack{} },
TypeHeartbeat: func() Message { return &Heartbeat{} },
TypeHeartbeat: func() Message { return &Heartbeat{} },
+ TypeReauth: func() Message { return &Reauth{} },
TypeEstopState: func() Message { return &EstopState{} },
TypeEstopState: func() Message { return &EstopState{} },
TypeJob: func() Message { return &JobRequest{} },
TypeJob: func() Message { return &JobRequest{} },
TypeJobCancel: func() Message { return &JobCancel{} },
TypeJobCancel: func() Message { return &JobCancel{} },
@@ -607,6 +624,8 @@ func derefMessage(m Message) Message {
@@ -607,6 +624,8 @@ func derefMessage(m Message) Message {
return *v
return *v
case *Heartbeat:
case *Heartbeat:
return *v
return *v
+ case *Reauth:
+ return *v
case *EstopState:
case *EstopState:
return *v
return *v
case *JobRequest:
case *JobRequest:
@@ -676,7 +695,8 @@ type ICEServer struct {
@@ -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
// 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 {
type PairResponse struct {
DeviceID string `json:"device_id"`
DeviceID string `json:"device_id"`
Credential string `json:"credential"`
Credential string `json:"credential"`
@@ -686,6 +706,43 @@ type PairResponse struct {
@@ -686,6 +706,43 @@ type PairResponse struct {
Profile json.RawMessage `json:"profile,omitempty"`
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.
// Problem is the RFC 9457 body of a non-2xx answer from Bot's REST API.
type Problem struct {
type Problem struct {
Status int `json:"status"`
Status int `json:"status"`
@@ -21,6 +21,7 @@ func TestEncodeDecodeRoundTrip(t *testing.T) {
@@ -21,6 +21,7 @@ func TestEncodeDecodeRoundTrip(t *testing.T) {
Ack{ID: "c1"},
Ack{ID: "c1"},
Nack{ID: "c1", FaultCode: FaultEstopped},
Nack{ID: "c1", FaultCode: FaultEstopped},
Heartbeat{Seq: 10},
Heartbeat{Seq: 10},
+ Reauth{Token: "eyJ.node.token"},
EstopState{Latched: true},
EstopState{Latched: true},
Error{Code: ErrNotPaired},
Error{Code: ErrNotPaired},
}
}
@@ -65,6 +65,10 @@ func (p *program) Stop(ks.Service) error {
@@ -65,6 +65,10 @@ func (p *program) Stop(ks.Service) error {
return nil
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.
// Options for the service definition.
type Options struct {
type Options struct {
// Arguments passed to the executable by the service manager (["run"] plus --home when set).
// Arguments passed to the executable by the service manager (["run"] plus --home when set).
@@ -116,6 +120,7 @@ StandardError=file:{{LogDirectory}}/{{Name}}.err
@@ -116,6 +120,7 @@ StandardError=file:{{LogDirectory}}/{{Name}}.err
{{end}}{{if Restart}}Restart={{Restart}}
{{end}}{{if Restart}}Restart={{Restart}}
{{end}}{{if SuccessExitStatus}}SuccessExitStatus={{SuccessExitStatus}}
{{end}}{{if SuccessExitStatus}}SuccessExitStatus={{SuccessExitStatus}}
{{end}}RestartSec=120
{{end}}RestartSec=120
+RestartPreventExitStatus=78
Delegate=yes
Delegate=yes
EnvironmentFile=-/etc/sysconfig/{{Name}}
EnvironmentFile=-/etc/sysconfig/{{Name}}
@@ -2,6 +2,7 @@ package service
@@ -2,6 +2,7 @@ package service
import (
import (
"context"
"context"
+ "fmt"
"strings"
"strings"
"testing"
"testing"
"time"
"time"
@@ -36,4 +37,8 @@ func TestConfig(t *testing.T) {
@@ -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") {
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)
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)
+ }
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -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"