openvibers/openvibe.link Commits

node: a guided first run that reports each step (#105)

OP OpenVibers committed 7 days ago · f8437ca8ea · 17 files, +1353 −9

* node: a guided first run that reports each step

A bare `openvibe-node` now walks a machine through its first run: pair with the code from
openvibe.bot/machines (or paste the installer command for a Network pairing), check that it
reaches OpenVibe.Bot and OpenVibe.Network, run one harmless first job (OS, CPUs, memory, free
disk) and print what to do next. `openvibe-node first-run` is the same command for scripts.

Each step is one `node.first_run` telemetry event over the device link the Node already has,
with the version and OS and no path, username or address. The installer starts the service
before the guide, so when a Node is running the events go through its local control socket;
otherwise the guide opens the link itself for the few frames. The install marks the installed
step, and docs/install.md describes the flow and its privacy.

* first run tests: run on Windows too

The report's 0600 mode and the first job's disk figures are POSIX/Linux-only;
assert them only there. The cross-compiled Windows tests now build.

Changes

ddiff --git a/README.md b/README.md
iindex 8bba743..9f5ba50 100644
---- a/README.md
++++ b/README.md
@@@ -30,6 +30,7 @@ serves it at `https://<name>--<you>.openvibe.website` behind OpenVibe sign-in, a
up with the service ([docs/connect.md](docs/connect.md)).
```sh
++openvibe-node # guided first run: pair, check the link, report this machine's first job
openvibe-node status # link, plugins, video, battery, stop latch
openvibe-node stop # kill switch: stop every motor now and keep it stopped
openvibe-node resume
ddiff --git a/cmd/openvibe-node/first_run.go b/cmd/openvibe-node/first_run.go
nnew file mode 100644
iindex 0000000..b505304
---- /dev/null
++++ b/cmd/openvibe-node/first_run.go
@@@ -0,0 +1,463 @@
++package main
++
++import (
++ "bufio"
++ "context"
++ "encoding/json"
++ "errors"
++ "flag"
++ "fmt"
++ "io"
++ "log/slog"
++ "os"
++ "runtime"
++ "sort"
++ "strings"
++ "sync"
++ "time"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/credentials"
++ "github.com/OpenVibers/OpenVibe.Node/internal/firstrun"
++ "github.com/OpenVibers/OpenVibe.Node/internal/link"
++ "github.com/OpenVibers/OpenVibe.Node/internal/localctl"
++ "github.com/OpenVibers/OpenVibe.Node/internal/protocol"
++)
++
++// The guided first run is what a bare `openvibe-node` does: pair (when needed), check the way to OpenVibe, run one
++// harmless first job (this machine's OS, CPUs, memory and free disk) and report one event per step to Bot over the
++// link the Node already uses. `openvibe-node first-run` is the same command, for scripts that want to say it.
++const firstRunDocs = "https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs/install.md"
++
++// guideArgs are the flags a bare `openvibe-node` (or `openvibe-node first-run`) takes. They mirror the pair flags so
++// a machine's installer command can be pasted at the prompt, and so scripts can pass the code and Network pairing.
++type guideArgs struct {
++ code, robot, name, server, network, pairing string
++}
++
++// parseGuideFlags parses the guide's own flags (global flags included) and returns the positional words.
++func parseGuideFlags(args []string, g *globals, a *guideArgs, stderr io.Writer) ([]string, error) {
++ fs := flag.NewFlagSet("openvibe-node", flag.ContinueOnError)
++ fs.SetOutput(stderr)
++ fs.StringVar(&g.home, "home", "", "config and state directory")
++ fs.StringVar(&g.logLevel, "log-level", "info", "log level")
++ fs.StringVar(&a.code, "code", "", "the pairing code, instead of the prompt")
++ fs.StringVar(&a.robot, "robot", "", "the rob_… id of the robot to pair with")
++ fs.StringVar(&a.name, "name", "", "a name for this device on openvibe.bot; default: the hostname")
++ fs.StringVar(&a.server, "server", "", "OpenVibe.Bot origin")
++ fs.StringVar(&a.network, "network", "", "OpenVibe.Network origin: pair this machine there")
++ fs.StringVar(&a.pairing, "pairing", "", "the pair_… id from the installer command (with --network)")
++ if err := fs.Parse(args); err != nil {
++ return nil, err
++ }
++ return fs.Args(), nil
++}
++
++// cmdFirstRun is the guided first run. It reads a pairing code from stdin when the machine is not paired yet and
++// never asks again once a credential exists.
++func cmdFirstRun(g *globals, a guideArgs, positional []string, stdin io.Reader, stdout io.Writer) error {
++ if len(positional) > 1 {
++ return errors.New("usage: openvibe-node [--code CODE] [--network URL --pairing pair_…] [--home DIR]")
++ }
++ if len(positional) == 1 && a.code == "" {
++ a.code = positional[0]
++ }
++ if err := g.paths.Ensure(); err != nil {
++ fmt.Fprintln(stdout, "The Node keeps its files where this account cannot write them. Run this with sudo:")
++ fmt.Fprintln(stdout, " sudo openvibe-node")
++ return fmt.Errorf("cannot use %s: %w", g.paths.ConfigDir, err)
++ }
++ if err := writable(g.paths.StateDir); err != nil {
++ fmt.Fprintln(stdout, "The Node's files belong to the service account. Run the first run with sudo:")
++ fmt.Fprintln(stdout, " sudo openvibe-node")
++ return fmt.Errorf("cannot write to %s: %w (run with sudo)", g.paths.StateDir, err)
++ }
++ fmt.Fprintln(stdout, "OpenVibe Node first run")
++ fmt.Fprintln(stdout, "======================")
++
++ report, err := firstrun.Load(g.paths.FirstRunFile())
++ if err != nil {
++ return err
++ }
++ if !report.Has(firstrun.StepInstalled) {
++ report.Upsert(firstrun.Step{Name: firstrun.StepInstalled, At: time.Now().UTC()})
++ }
++
++ creds, err := credentials.Load(g.paths.CredentialFile(), func(s string) { g.log.Warn(s) })
++ if errors.Is(err, credentials.ErrNotPaired) {
++ fmt.Fprintln(stdout)
++ fmt.Fprintln(stdout, "Get a pairing code on openvibe.bot (machines: Add a machine), then enter it here.")
++ fmt.Fprintln(stdout, "You can also paste the whole install command it shows.")
++ line, rerr := readPrompt(stdin, stdout, "Pairing code: ")
++ if rerr != nil {
++ return rerr
++ }
++ pa, perr := parsePairingInput(line)
++ if perr != nil {
++ return perr
++ }
++ // Flags on the command line win over the pasted command, which is what a script expects.
++ if a.code != "" {
++ pa.code = a.code
++ }
++ if a.robot != "" {
++ pa.robot = a.robot
++ }
++ if a.name != "" {
++ pa.name = a.name
++ }
++ if a.server != "" {
++ pa.server = a.server
++ }
++ if a.network != "" {
++ pa.network = a.network
++ }
++ if a.pairing != "" {
++ pa.pairing = a.pairing
++ }
++ creds, err = pairFromGuide(g, pa, stdout)
++ if err != nil {
++ fmt.Fprintln(stdout)
++ fmt.Fprintln(stdout, "That did not pair. Codes work once and expire; get a fresh one at openvibe.bot/machines")
++ fmt.Fprintln(stdout, "and run `openvibe-node` again.")
++ return err
++ }
++ at := creds.PairedAt
++ if at.IsZero() {
++ at = time.Now().UTC()
++ }
++ report.Upsert(firstrun.Step{Name: firstrun.StepPaired, At: at})
++ fmt.Fprintf(stdout, "Paired as %s.\n", creds.Label())
++ } else if err != nil {
++ return err
++ } else {
++ // A pairing done by the installer (or `openvibe-node pair`) already has its time; keep it for the funnel.
++ if !report.Has(firstrun.StepPaired) {
++ at := creds.PairedAt
++ if at.IsZero() {
++ at = time.Now().UTC()
++ }
++ report.Upsert(firstrun.Step{Name: firstrun.StepPaired, At: at})
++ }
++ // A Network pairing whose bind on Bot failed at pairing time binds now, so the events have a device to land on.
++ if creds.NetworkPaired() && !creds.Bound() {
++ cfg, cerr := loadConfig(g)
++ if cerr != nil {
++ return cerr
++ }
++ ts := link.NewTokenSource(creds, nil, "openvibe-node/"+version)
++ if creds, err = bindOnStart(g, creds, ts, cfg.Server); err != nil {
++ return err
++ }
++ }
++ if err := report.Save(g.paths.FirstRunFile()); err != nil {
++ g.log.Warn("could not save the first-run record", "err", err)
++ }
++ fmt.Fprintf(stdout, "Paired as %s.\n", creds.Label())
++ }
++
++ // (b) The self-check: both services the machine talks to.
++ fmt.Fprintln(stdout)
++ botURL := creds.Server
++ if botURL == "" {
++ if cfg, cerr := loadConfig(g); cerr == nil {
++ botURL = cfg.Server
++ }
++ }
++ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
++ ok, reason := firstrun.Check(ctx, nil, botURL, creds.Network)
++ cancel()
++ report.Upsert(firstrun.Step{Name: firstrun.StepCheck, At: time.Now().UTC(), OK: ok, Reason: reason})
++ if ok {
++ if creds.Network != "" {
++ fmt.Fprintln(stdout, "Self-check: OpenVibe.Bot and OpenVibe.Network answer.")
++ } else {
++ fmt.Fprintln(stdout, "Self-check: OpenVibe.Bot answers.")
++ }
++ } else {
++ fmt.Fprintf(stdout, "Self-check: FAILED: %s.\n", reason)
++ fmt.Fprintln(stdout, "The Node keeps trying in the background; check this machine's network and firewall.")
++ }
++
++ // (c) The first job: a harmless local report of this machine, sent as one event (never a path, user or address).
++ info := firstrun.Gather(g.paths.StateDir)
++ report.Upsert(firstrun.Step{Name: firstrun.StepFirstJob, At: time.Now().UTC(), Fields: info.Fields(version)})
++ fmt.Fprintln(stdout)
++ fmt.Fprintln(stdout, "First job: this machine")
++ fmt.Fprintf(stdout, " system %s/%s, %d CPUs\n", info.OS, info.Arch, info.CPUs)
++ if info.MemoryTotal > 0 {
++ fmt.Fprintf(stdout, " memory %s free of %s\n", bytesText(info.MemoryFree), bytesText(info.MemoryTotal))
++ }
++ if info.DiskTotal > 0 {
++ fmt.Fprintf(stdout, " disk %s free of %s\n", bytesText(info.DiskFree), bytesText(info.DiskTotal))
++ }
++ fmt.Fprintf(stdout, " node %s\n", version)
++
++ if err := report.Save(g.paths.FirstRunFile()); err != nil {
++ return err
++ }
++ events := report.Events(version, info.OS, info.Arch)
++ if len(events) > 0 {
++ if err := sendFirstRun(g, creds, events); err != nil {
++ fmt.Fprintf(stdout, "Could not report this first run (%v). The report is kept; run `openvibe-node` again when the link is up.\n", err)
++ } else {
++ fmt.Fprintln(stdout, "Reported this first run to OpenVibe.Bot.")
++ }
++ }
++
++ // (d) What it can do next, in one line, with the docs.
++ fmt.Fprintln(stdout)
++ fmt.Fprintln(stdout, "Next: `openvibe-node status` shows this machine, `openvibe-node stop` stops everything, and")
++ fmt.Fprintf(stdout, "your account lists it at https://openvibe.bot/machines. Docs: %s\n", firstRunDocs)
++ return nil
++}
++
++// pairFromGuide redeems the code and stores the credential, the same two paths `openvibe-node pair` takes.
++func pairFromGuide(g *globals, a pairArgs, stdout io.Writer) (*credentials.Credentials, error) {
++ cfg, noConfig, err := preparePair(g, a)
++ if err != nil {
++ return nil, err
++ }
++ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
++ defer cancel()
++ ua := "openvibe-node/" + version
++ var creds *credentials.Credentials
++ if a.network != "" || a.pairing != "" {
++ code, err := link.NormalizeCode(a.code)
++ if err != nil {
++ return nil, err
++ }
++ if a.pairing != "" && !link.PairingIDRe.MatchString(a.pairing) {
++ return nil, 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 nil, errors.New("--pairing needs --network, the OpenVibe.Network URL from the installer command")
++ }
++ if creds, err = link.PairNetwork(ctx, nil, network, protocol.NodePairingRequest{Code: code, Pairing: a.pairing, Name: deviceName(a.name)}, ua); err != nil {
++ return nil, err
++ }
++ creds.Server = strings.TrimSuffix(cfg.Server, "/")
++ if err := saveCredentials(g, creds); err != nil {
++ return nil, err
++ }
++ if bound, berr := link.Bind(ctx, nil, creds, link.NewTokenSource(creds, nil, ua), ua); berr != nil {
++ if errors.Is(berr, link.ErrNodeRevoked) {
++ return nil, berr
++ }
++ fmt.Fprintf(stdout, "Paired on %s; the device binds when the Node starts.\n", network)
++ } else {
++ if err := saveCredentials(g, bound); err != nil {
++ return nil, err
++ }
++ creds = bound
++ }
++ } else {
++ code, err := link.NormalizeCode(a.code)
++ if err != nil {
++ return nil, err
++ }
++ if a.robot != "" && !link.RobotRe.MatchString(a.robot) {
++ return nil, fmt.Errorf("--robot must be a robot id like rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X")
++ }
++ descs, _ := probeAll(cfg, g.paths, g.log)
++ req := protocol.PairRequest{Robot: a.robot, Code: code, AgentVersion: version, DeviceKind: cfg.DeviceKind, Drivers: []string{},
++ Capabilities: map[string]any{}, Name: deviceName(a.name)}
++ names := make([]string, 0, len(descs))
++ for n := range descs {
++ names = append(names, n)
++ }
++ sort.Strings(names)
++ for _, n := range names {
++ req.Drivers = append(req.Drivers, descs[n].Driver)
++ req.Capabilities[n] = descs[n].Capabilities
++ }
++ if creds, err = link.Pair(ctx, nil, cfg.Server, req); err != nil {
++ return nil, err
++ }
++ if err := saveCredentials(g, creds); err != nil {
++ return nil, err
++ }
++ }
++ if err := finishPair(g, a, cfg, noConfig, stdout); err != nil {
++ return nil, err
++ }
++ return creds, nil
++}
++
++// writable makes the guide fail with the sudo hint before it asks for a code, not after, when the state directory
++// exists but belongs to the service account.
++func writable(dir string) error {
++ f, err := os.CreateTemp(dir, ".first-run-*")
++ if err != nil {
++ return err
++ }
++ name := f.Name()
++ f.Close()
++ return os.Remove(name)
++}
++
++// readPrompt writes prompt and reads one line; a line without a trailing newline at EOF is still a line.
++func readPrompt(r io.Reader, w io.Writer, prompt string) (string, error) {
++ if r == nil {
++ return "", errors.New("no pairing code was entered")
++ }
++ fmt.Fprint(w, prompt)
++ line, err := bufio.NewReader(r).ReadString('\n')
++ if err != nil && !errors.Is(err, io.EOF) {
++ return "", err
++ }
++ line = strings.TrimSpace(line)
++ if line == "" {
++ return "", errors.New("no pairing code was entered")
++ }
++ return line, nil
++}
++
++// parsePairingInput takes a code, or the whole installer command from openvibe.bot/machines (curl … sh -s -- --network
++// URL --pairing pair_… --code CODE --worker). Reading the command is how a machine paired through Network gets the
++// network and pairing id from one prompt.
++func parsePairingInput(line string) (pairArgs, error) {
++ line = strings.TrimSpace(line)
++ if line == "" {
++ return pairArgs{}, errors.New("no pairing code was entered")
++ }
++ fields := strings.Fields(line)
++ if len(fields) == 1 && !strings.HasPrefix(fields[0], "-") {
++ return pairArgs{code: fields[0]}, nil
++ }
++ var a pairArgs
++ for i := 0; i < len(fields); i++ {
++ f := fields[i]
++ key, val, hasVal := strings.Cut(f, "=")
++ set := func(dst *string) {
++ if hasVal {
++ *dst = val
++ } else if i+1 < len(fields) {
++ i++
++ *dst = fields[i]
++ }
++ }
++ switch key {
++ case "--code":
++ set(&a.code)
++ case "--network":
++ set(&a.network)
++ case "--pairing":
++ set(&a.pairing)
++ case "--robot":
++ set(&a.robot)
++ case "--name":
++ set(&a.name)
++ case "--server":
++ set(&a.server)
++ }
++ }
++ if a.code == "" {
++ // `openvibe-node pair ABCD-1234` pasted without --code.
++ for i := 0; i+1 < len(fields); i++ {
++ if fields[i] == "pair" && !strings.HasPrefix(fields[i+1], "-") {
++ a.code = fields[i+1]
++ break
++ }
++ }
++ }
++ if a.code == "" {
++ return pairArgs{}, errors.New("that is neither a pairing code (like ABCD-1234) nor an install command with --code")
++ }
++ return a, nil
++}
++
++// sendFirstRun sends the events through the running Node when there is one (the installer starts it), and otherwise
++// opens the device link for the few frames itself, so a manual install reports the same way.
++func sendFirstRun(g *globals, creds *credentials.Credentials, events []protocol.Event) error {
++ data, err := jsonTelemetry(events)
++ if err != nil {
++ return err
++ }
++ if resp, err := localctl.Call(g.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdFirstRun, Data: data}, 5*time.Second); err == nil && resp.OK {
++ return nil
++ }
++ return sendFirstRunDirect(creds, events)
++}
++
++func jsonTelemetry(events []protocol.Event) ([]byte, error) {
++ return json.Marshal(protocol.Telemetry{Events: events})
++}
++
++func sendFirstRunDirect(creds *credentials.Credentials, events []protocol.Event) error {
++ if creds.Server == "" {
++ return errors.New("no OpenVibe.Bot address is stored")
++ }
++ u, err := link.DeviceURL(creds.Server)
++ if err != nil {
++ return err
++ }
++ h := &firstRunHandler{ready: make(chan struct{})}
++ opts := link.Options{URL: u, Credential: creds.Credential, UserAgent: "openvibe-node/" + version,
++ HeartbeatInterval: 200 * time.Millisecond, Log: slog.New(slog.NewTextHandler(io.Discard, nil))}
++ if creds.NetworkPaired() {
++ opts.Credential = credentials.Secret{}
++ opts.Tokens = link.NewTokenSource(creds, nil, opts.UserAgent)
++ }
++ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
++ defer cancel()
++ l := link.New(opts, h)
++ go l.Run(ctx)
++ select {
++ case <-h.ready:
++ case <-ctx.Done():
++ return errors.New("OpenVibe.Bot did not answer")
++ }
++ st := protocol.Status{Firmware: "openvibe-node-" + version, AgentVersion: version, Capabilities: map[string]any{},
++ OS: runtime.GOOS, Arch: runtime.GOARCH, Drivers: []protocol.DriverStatus{}, Faults: []protocol.Fault{}}
++ if err := l.Send(st); err != nil {
++ return err
++ }
++ for _, e := range events {
++ if err := l.Send(protocol.Telemetry{Events: []protocol.Event{e}}); err != nil {
++ return err
++ }
++ }
++ // The link's own heartbeats (100 ms after hello) come after these frames on the same socket; two of them prove
++ // the frames were written and answered. The device link acks heartbeats itself, so this is the only signal.
++ select {
++ case <-time.After(2 * time.Second):
++ case <-ctx.Done():
++ return errors.New("OpenVibe.Bot did not answer")
++ }
++ return nil
++}
++
++// firstRunHandler only needs the config (the link is ready) and the next heartbeat ack (the frames went out).
++type firstRunHandler struct {
++ ready chan struct{}
++ ack chan struct{}
++ once sync.Once
++}
++
++func (h *firstRunHandler) Connected() {}
++func (h *firstRunHandler) Disconnected(error) {}
++func (h *firstRunHandler) Frame(f protocol.Frame) {
++ switch f.Msg.(type) {
++ case protocol.Config:
++ h.once.Do(func() { close(h.ready) })
++ case protocol.HeartbeatAck:
++ select {
++ case h.ack <- struct{}{}:
++ default:
++ }
++ }
++}
++
++func bytesText(n uint64) string {
++ const unit = 1024
++ if n < unit {
++ return fmt.Sprintf("%d B", n)
++ }
++ div, exp := uint64(unit), 0
++ for m := n / unit; m >= unit; m /= unit {
++ div *= unit
++ exp++
++ }
++ return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGTPE"[exp])
++}
ddiff --git a/cmd/openvibe-node/first_run_test.go b/cmd/openvibe-node/first_run_test.go
nnew file mode 100644
iindex 0000000..ef4f315
---- /dev/null
++++ b/cmd/openvibe-node/first_run_test.go
@@@ -0,0 +1,196 @@
++package main
++
++import (
++ "bytes"
++ "encoding/json"
++ "fmt"
++ "net/http"
++ "os"
++ "path/filepath"
++ "runtime"
++ "strings"
++ "testing"
++ "time"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/fakebot"
++ "github.com/OpenVibers/OpenVibe.Node/internal/fakenetwork"
++ "github.com/OpenVibers/OpenVibe.Node/internal/firstrun"
++ "github.com/OpenVibers/OpenVibe.Node/internal/protocol"
++ "github.com/OpenVibers/OpenVibe.Node/internal/service"
++)
++
++// quietService keeps the guide from asking a service manager on the test host.
++func quietService(t *testing.T) {
++ t.Helper()
++ old := serviceStatus
++ serviceStatus = func(service.Options) string { return "not installed" }
++ t.Cleanup(func() { serviceStatus = old })
++}
++
++func guide(t *testing.T, home string, stdin string, args ...string) (string, string, int) {
++ t.Helper()
++ t.Setenv("PYTHONPATH", pythonPath(t))
++ var out, errb bytes.Buffer
++ code := runWithStdin(append([]string{"--home", home}, args...), strings.NewReader(stdin), &out, &errb)
++ return out.String(), errb.String(), code
++}
++
++func telemetryEvents(t *testing.T, srv *fakebot.Server) []protocol.Event {
++ t.Helper()
++ c, err := srv.NextConn(5 * time.Second)
++ if err != nil {
++ t.Fatal(err)
++ }
++ var events []protocol.Event
++ for _, f := range c.Frames() {
++ if tel, ok := f.Msg.(protocol.Telemetry); ok {
++ events = append(events, tel.Events...)
++ }
++ }
++ return events
++}
++
++func TestFirstRunWrongCode(t *testing.T) {
++ quietService(t)
++ srv := fakebot.New()
++ defer srv.Close()
++ srv.AddCode("ABCD2345")
++ home := shortHome(t)
++ out, errOut, code := guide(t, home, "ZZZZ-9999\n", "--server", srv.URL())
++ if code != 1 {
++ t.Fatalf("exit %d, out %s err %s", code, out, errOut)
++ }
++ if !strings.Contains(out, "Pairing code: ") || !strings.Contains(out, "That did not pair") || !strings.Contains(errOut, "not the pairing code") {
++ t.Fatalf("out %q err %q", out, errOut)
++ }
++ if _, err := os.Stat(filepath.Join(home, "credential.json")); !os.IsNotExist(err) {
++ t.Fatal("a credential was written for a wrong code")
++ }
++ if n := len(srv.PairRequests()); n != 1 {
++ t.Fatalf("%d pair requests", n)
++ }
++}
++
++func TestFirstRunSuccessReportsOneEventPerStep(t *testing.T) {
++ quietService(t)
++ srv := fakebot.New()
++ defer srv.Close()
++ srv.AddCode("ABCD2345")
++ home := shortHome(t)
++ out, errOut, code := guide(t, home, "abcd-2345\n", "--server", srv.URL())
++ if code != 0 {
++ t.Fatalf("exit %d, out %s err %s", code, out, errOut)
++ }
++ for _, want := range []string{"OpenVibe Node first run", "Paired as", "Self-check: OpenVibe.Bot answers.",
++ "First job: this machine", "system " + runtime.GOOS, "Reported this first run to OpenVibe.Bot.", "Next:", "docs/install.md"} {
++ if !strings.Contains(out, want) {
++ t.Fatalf("missing %q in output:\n%s", want, out)
++ }
++ }
++ // The record is on disk, mode 0600, and every step is there.
++ r, err := firstrun.Load(filepath.Join(home, firstrun.FileName))
++ if err != nil || len(r.Steps) != 4 {
++ t.Fatalf("%v %+v", err, r)
++ }
++ events := telemetryEvents(t, srv)
++ if len(events) != 4 {
++ t.Fatalf("%d events: %+v", len(events), events)
++ }
++ for i, step := range []string{firstrun.StepInstalled, firstrun.StepPaired, firstrun.StepCheck, firstrun.StepFirstJob} {
++ e := events[i]
++ if e.Name != firstrun.EventName || e.Fields["step"] != step || e.Fields["version"] != version || e.Fields["os"] != runtime.GOOS {
++ t.Fatalf("event %d %+v", i, e)
++ }
++ }
++ if events[2].Fields["ok"] != true {
++ t.Fatalf("self-check %+v", events[2])
++ }
++ if events[3].Fields["cpus"] == nil || (runtime.GOOS == "linux" && events[3].Fields["disk_total_bytes"] == nil) {
++ t.Fatalf("first job has no machine figures: %+v", events[3])
++ }
++ // No personal data, path, username or IP in any event.
++ b, _ := json.Marshal(events)
++ host, _ := os.Hostname()
++ for _, secret := range []string{host, os.Getenv("USER"), os.Getenv("HOME"), home, "127.0.0.1", "/tmp/"} {
++ if secret != "" && strings.Contains(string(b), secret) {
++ t.Fatalf("%q leaked in %s", secret, b)
++ }
++ }
++}
++
++func TestFirstRunSelfCheckFailureIsReportedWithTheReason(t *testing.T) {
++ quietService(t)
++ srv := fakebot.New()
++ defer srv.Close()
++ srv.AddCode("ABCD2345")
++ srv.SetHealth(http.StatusServiceUnavailable)
++ home := shortHome(t)
++ out, errOut, code := guide(t, home, "ABCD-2345\n", "--server", srv.URL())
++ if code != 0 {
++ t.Fatalf("exit %d, out %s err %s", code, out, errOut)
++ }
++ if !strings.Contains(out, "Self-check: FAILED: OpenVibe.Bot answered HTTP 503.") {
++ t.Fatalf("output:\n%s", out)
++ }
++ events := telemetryEvents(t, srv)
++ if len(events) != 4 || events[2].Fields["step"] != firstrun.StepCheck || events[2].Fields["ok"] != false {
++ t.Fatalf("%+v", events)
++ }
++ if reason, _ := events[2].Fields["reason"].(string); !strings.Contains(reason, "HTTP 503") {
++ t.Fatalf("reason %q", reason)
++ }
++}
++
++// The machines page's install command pasted at the one prompt: Network pairing, then the same four events.
++func TestFirstRunNetworkPairingFromTheInstallCommand(t *testing.T) {
++ quietService(t)
++ bot, net := fakes(t)
++ home := shortHome(t)
++ command := fmt.Sprintf("curl -fsSL https://openvibe.bot/install | sh -s -- --network %s --pairing %s --code abcd-2345 --worker",
++ net.URL(), fakenetwork.PairingID)
++ out, errOut, code := guide(t, home, command+"\n", "--server", bot.URL())
++ if code != 0 {
++ t.Fatalf("exit %d, out %s err %s", code, out, errOut)
++ }
++ if !strings.Contains(out, "Paired as "+fakenetwork.DeviceID) || !strings.Contains(out, "Self-check: OpenVibe.Bot and OpenVibe.Network answer.") {
++ t.Fatalf("output:\n%s", out)
++ }
++ if p := net.Pairings(); len(p) != 1 || p[0].Code != "ABCD2345" || p[0].Pairing != fakenetwork.PairingID {
++ t.Fatalf("%+v", p)
++ }
++ if bot.Binds() == 0 {
++ t.Fatal("the machine was not bound on Bot")
++ }
++ events := telemetryEvents(t, bot)
++ if len(events) != 4 || events[3].Fields["step"] != firstrun.StepFirstJob {
++ t.Fatalf("%+v", events)
++ }
++}
++
++func TestParsePairingInput(t *testing.T) {
++ cases := []struct {
++ in string
++ code, network, pairing, robot string
++ ok bool
++ }{
++ {"ABCD-1234", "ABCD-1234", "", "", "", true},
++ {"openvibe-node pair ABCD-1234", "ABCD-1234", "", "", "", true},
++ {"curl -fsSL https://openvibe.bot/install | sh -s -- --network https://openvibe.network --pairing pair_01J8Z4M2Q0R7T9YV3K6N8P1W2Y --code ABCD-1234 --worker",
++ "ABCD-1234", "https://openvibe.network", "pair_01J8Z4M2Q0R7T9YV3K6N8P1W2Y", "", true},
++ {"openvibe-node pair --code=ABCD-1234 --robot rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X", "ABCD-1234", "", "", "rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X", true},
++ {"", "", "", "", "", false},
++ {"curl https://openvibe.bot/install | sh", "", "", "", "", false},
++ }
++ for _, c := range cases {
++ a, err := parsePairingInput(c.in)
++ if (err == nil) != c.ok {
++ t.Fatalf("%q: %v", c.in, err)
++ }
++ if err != nil {
++ continue
++ }
++ if a.code != c.code || a.network != c.network || a.pairing != c.pairing || a.robot != c.robot {
++ t.Fatalf("%q → %+v", c.in, a)
++ }
++ }
++}
ddiff --git a/cmd/openvibe-node/main.go b/cmd/openvibe-node/main.go
iindex b3a7183..f8ea733 100644
---- a/cmd/openvibe-node/main.go
++++ b/cmd/openvibe-node/main.go
@@@ -1,5 +1,6 @@
// Command openvibe-node connects a computer, server, Raspberry Pi or robot to OpenVibe.
//
++// openvibe-node guided first run: pair, self-check, one first job, then what to do next
// 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
@@@ -36,6 +37,7 @@ import (
"github.com/OpenVibers/OpenVibe.Node/internal/config"
"github.com/OpenVibers/OpenVibe.Node/internal/connect"
"github.com/OpenVibers/OpenVibe.Node/internal/credentials"
++ "github.com/OpenVibers/OpenVibe.Node/internal/firstrun"
"github.com/OpenVibers/OpenVibe.Node/internal/link"
"github.com/OpenVibers/OpenVibe.Node/internal/localctl"
"github.com/OpenVibers/OpenVibe.Node/internal/node"
@@@ -52,6 +54,9 @@ var version = "dev"
const usage = `openvibe-node %s: connect this device to OpenVibe.
Usage:
++ openvibe-node [--code CODE] [--network URL --pairing pair_…] [--home DIR]
++ guided first run: pair with a code from openvibe.bot/machines, check this machine can reach
++ OpenVibe, run one harmless first job, and report each step; the same as 'openvibe-node first-run'
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
@@@ -106,10 +111,35 @@ func run(args []string, stdout, stderr io.Writer) int {
// runWithStdin is run with the command's stdin, so tests can feed `credential import`.
func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
-- if len(args) == 0 || args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
++ if len(args) > 0 && (args[0] == "-h" || args[0] == "--help" || args[0] == "help" || args[0] == "--version") {
++ if args[0] == "--version" {
++ fmt.Fprintf(stdout, "openvibe-node %s %s/%s\n", version, runtime.GOOS, runtime.GOARCH)
++ return 0
++ }
fmt.Fprintf(stdout, usage, version)
return 0
}
++ // A bare `openvibe-node` (or flags with no command) is the guided first run; it needs its own flag set, because
++ // its flags include the pairing ones and there is no command word to hang them on.
++ if len(args) == 0 || args[0] == "first-run" || strings.HasPrefix(args[0], "-") {
++ g := &globals{}
++ var ga guideArgs
++ rest := args
++ if len(args) > 0 && args[0] == "first-run" {
++ rest = args[1:]
++ }
++ positional, err := parseGuideFlags(rest, g, &ga, stderr)
++ if err != nil {
++ return 2
++ }
++ g.paths = config.DefaultPaths(g.home)
++ g.log = newLogger(stderr, g.logLevel)
++ if err := cmdFirstRun(g, ga, positional, stdin, stdout); err != nil {
++ fmt.Fprintln(stderr, "error:", err)
++ return 1
++ }
++ return 0
++ }
cmd, rest := args[0], args[1:]
if cmd == "memory" {
// The memory scan has its own flags, so it parses its own arguments.
@@@ -652,8 +682,13 @@ func cmdInstall(g *globals, user string, stdout io.Writer) error {
return fmt.Errorf("installed, but could not start: %w", err)
}
fmt.Fprintln(stdout, "Installed and started the openvibe-node service.")
++ // The guided first run's clock starts here. Best effort: a record that cannot be written never fails an install.
++ if r, rerr := firstrun.Load(g.paths.FirstRunFile()); rerr == nil && !r.Has(firstrun.StepInstalled) {
++ r.Upsert(firstrun.Step{Name: firstrun.StepInstalled, At: time.Now().UTC()})
++ _ = r.Save(g.paths.FirstRunFile())
++ }
if _, err := credentials.Load(g.paths.CredentialFile(), nil); err != nil {
-- fmt.Fprintln(stdout, "Not paired yet: run `sudo openvibe-node pair <CODE>`.")
++ fmt.Fprintln(stdout, "Not paired yet: run `sudo openvibe-node first-run` to pair and finish the setup.")
}
return nil
}
ddiff --git a/docs/install.md b/docs/install.md
iindex d23cd98..2793a7e 100644
---- a/docs/install.md
++++ b/docs/install.md
@@@ -111,6 +111,28 @@ Requirements: `python3` with `venv` (`sudo apt install python3 python3-venv`), `
`rpicam-apps` (preinstalled on Raspberry Pi OS). For Cozmo's voice, `espeak-ng`. `ffmpeg` is optional (better video
from plugins that send JPEG frames).
++## First run (guided and measured)
++
++After the installer, run the Node with no command:
++
++```sh
++sudo openvibe-node
++```
++
++It walks through the first run once: it asks for the code from openvibe.bot/machines if the machine is not paired yet
++(pasting the whole install command there works too, for a Network pairing), checks that the machine reaches
++OpenVibe.Bot and OpenVibe.Network, runs one harmless first job that reads the machine's OS, CPUs, memory and free
++disk, and prints what to do next. The job opens nothing and runs as the service account; it only reads those counts.
++
++The Node reports one telemetry event per step (`installed`, `paired`, `self_check` with ok or a reason, `first_job`)
++over the same device link it already uses. An event carries the Node version, the OS and architecture, and for the
++first job those counts: never a path, a username, an IP or a secret. Bot stores the step times, so an admin can see how
++many installs reach each step and how long they take (`/admin/first-run`), and the machine's page shows what the first
++job found.
++
++`openvibe-node first-run` is the same command when a script wants to say it. `--code CODE`, `--network URL
++--pairing pair_…` and `--home DIR` work as flags instead of the prompt.
++
## By hand
```sh
ddiff --git a/install/install.sh b/install/install.sh
iindex f1b6ff6..2563034 100755
---- a/install/install.sh
++++ b/install/install.sh
@@@ -1087,3 +1087,4 @@ print(" camera %s%s" % (v.get("state") or "off", (" (" + v["last_error"] + ")
fi
say "done. Check it with: sudo openvibe-node status Stop everything with: sudo openvibe-node stop"
++say "first run: sudo openvibe-node (checks the link and reports this machine's first job on openvibe.bot/machines)"
ddiff --git a/internal/config/config.go b/internal/config/config.go
iindex 539b4a9..e122e9d 100644
---- a/internal/config/config.go
++++ b/internal/config/config.go
@@@ -47,6 +47,7 @@ func (p Paths) SocketPath() string { return filepath.Join(p.StateDir, "node.
func (p Paths) PluginsDir() string { return filepath.Join(p.StateDir, "plugins") }
func (p Paths) VenvDir() string { return filepath.Join(p.StateDir, "venv") }
func (p Paths) FramesDir() string { return filepath.Join(p.StateDir, "frames") }
++func (p Paths) FirstRunFile() string { return filepath.Join(p.StateDir, "firstrun.json") }
// Ensure creates the directories with owner-only permissions.
func (p Paths) Ensure() error {
ddiff --git a/internal/fakebot/fakebot.go b/internal/fakebot/fakebot.go
iindex 1786b33..d043085 100644
---- a/internal/fakebot/fakebot.go
++++ b/internal/fakebot/fakebot.go
@@@ -58,9 +58,10 @@ type Server struct {
credInURL bool
binds atomic.Int64
-- conns chan *Conn
-- count atomic.Int64
-- dials atomic.Int64
++ conns chan *Conn
++ count atomic.Int64
++ dials atomic.Int64
++ health atomic.Int64 // GET /api/health status; 200 unless a test sets it
}
type refusal struct {
@@@ -71,9 +72,13 @@ type refusal struct {
// RobotID is the robot AddCode and AddCredential attach devices to.
const RobotID = "rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X"
++// SetHealth makes the fake /api/health answer with status (the guide's self-check reads it).
++func (s *Server) SetHealth(status int) { s.health.Store(int64(status)) }
++
// New starts the fake server.
func New() *Server {
s := &Server{codes: map[string]string{}, creds: map[string]string{}, robots: map[string]string{}, live: map[string]*Conn{}}
++ s.health.Store(http.StatusOK)
s.estop = map[string]bool{}
s.conns = make(chan *Conn, 16)
s.Config = protocol.Config{HeartbeatMS: 1000, Limits: protocol.Limits{MaxCommandMS: 1000, HeartbeatMS: 1000}}
@@@ -82,6 +87,12 @@ func New() *Server {
mux.HandleFunc(protocol.PairPath, s.pair)
mux.HandleFunc(protocol.DevicePath, s.device)
mux.HandleFunc(protocol.BindPath, s.bind)
++ mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
++ status := int(s.health.Load())
++ w.Header().Set("Content-Type", "application/json")
++ w.WriteHeader(status)
++ _, _ = w.Write([]byte(`{"ok":true}`))
++ })
mux.HandleFunc("/whip/", func(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
h := s.WHIP
ddiff --git a/internal/fakenetwork/fakenetwork.go b/internal/fakenetwork/fakenetwork.go
iindex 563d496..7ad0428 100644
---- a/internal/fakenetwork/fakenetwork.go
++++ b/internal/fakenetwork/fakenetwork.go
@@@ -34,6 +34,7 @@ type Server struct {
bot *fakebot.Server
mu sync.Mutex
++ health int // GET /api/health status; 200 unless a test sets it
codes map[string]bool // normalized code → live
credential string // the node credential, once paired
revoked bool
@@@ -44,10 +45,18 @@ type Server struct {
// 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{}}
++ s := &Server{TTL: 300, bot: bot, codes: map[string]bool{}, health: http.StatusOK}
mux := http.NewServeMux()
mux.HandleFunc(protocol.NodePairingPath, s.pair)
mux.HandleFunc(protocol.TokenPath, s.token)
++ mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
++ s.mu.Lock()
++ status := s.health
++ s.mu.Unlock()
++ w.Header().Set("Content-Type", "application/json")
++ w.WriteHeader(status)
++ _, _ = w.Write([]byte(`{"ok":true}`))
++ })
s.HTTP = httptest.NewServer(mux)
return s
}
@@@ -55,6 +64,13 @@ func New(bot *fakebot.Server) *Server {
func (s *Server) Close() { s.HTTP.Close() }
func (s *Server) URL() string { return s.HTTP.URL }
++// SetHealth makes the fake /api/health answer with status (the guide's self-check reads it).
++func (s *Server) SetHealth(status int) {
++ s.mu.Lock()
++ s.health = status
++ s.mu.Unlock()
++}
++
// AddCode makes a one-time code (normalized, without the dash) live for PairingID.
func (s *Server) AddCode(code string) {
s.mu.Lock()
ddiff --git a/internal/firstrun/firstrun.go b/internal/firstrun/firstrun.go
nnew file mode 100644
iindex 0000000..6823ed9
---- /dev/null
++++ b/internal/firstrun/firstrun.go
@@@ -0,0 +1,312 @@
++// Package firstrun is the guided first run's local record: which steps this machine reached, what it found, and the
++// one telemetry event per step the Node reports to Bot. The record never holds a path, a username, an IP or a secret:
++// it is the material for `node.first_run` events and for the terminal, nothing else.
++package firstrun
++
++import (
++ "context"
++ "encoding/json"
++ "errors"
++ "fmt"
++ "net"
++ "net/http"
++ "os"
++ "path/filepath"
++ "runtime"
++ "strings"
++ "time"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/protocol"
++)
++
++// EventName is the telemetry event the Node sends for every step. Bot stores these as the machine's first run.
++const EventName = "node.first_run"
++
++// The steps, in order. A self-check that failed is still the self_check step (with ok false and a reason), so a
++// funnel never counts a failure as a step it did not reach.
++const (
++ StepInstalled = "installed"
++ StepPaired = "paired"
++ StepCheck = "self_check"
++ StepFirstJob = "first_job"
++)
++
++// Steps is the order the guide runs them in.
++var Steps = []string{StepInstalled, StepPaired, StepCheck, StepFirstJob}
++
++// Step is one reached step: when, whether the self-check passed, and what the first job found.
++type Step struct {
++ Name string `json:"name"`
++ At time.Time `json:"at"`
++ OK bool `json:"ok,omitempty"`
++ Reason string `json:"reason,omitempty"`
++ Fields map[string]any `json:"fields,omitempty"`
++}
++
++// Report is the on-disk first-run record (firstrun.json in the state directory, mode 0600).
++type Report struct {
++ Version int `json:"version"`
++ Steps []Step `json:"steps"`
++}
++
++// FileName is the record's name in the Node's state directory.
++const FileName = "firstrun.json"
++
++// Load reads the record; a missing one is an empty report, not an error.
++func Load(path string) (*Report, error) {
++ b, err := os.ReadFile(path)
++ if errors.Is(err, os.ErrNotExist) {
++ return &Report{Version: 1}, nil
++ }
++ if err != nil {
++ return nil, err
++ }
++ var r Report
++ if err := json.Unmarshal(b, &r); err != nil {
++ // A torn or old record must not stop the guide: start over.
++ return &Report{Version: 1}, nil
++ }
++ r.Version = 1
++ return &r, nil
++}
++
++// Save writes the record atomically with mode 0600.
++func (r *Report) Save(path string) error {
++ b, err := json.MarshalIndent(r, "", " ")
++ if err != nil {
++ return err
++ }
++ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
++ return err
++ }
++ tmp, err := os.CreateTemp(filepath.Dir(path), ".firstrun-*")
++ if err != nil {
++ return err
++ }
++ name := tmp.Name()
++ defer os.Remove(name)
++ if err := tmp.Chmod(0o600); err != nil {
++ tmp.Close()
++ return err
++ }
++ if _, err := tmp.Write(b); err != nil {
++ tmp.Close()
++ return err
++ }
++ if err := tmp.Close(); err != nil {
++ return err
++ }
++ return os.Rename(name, path)
++}
++
++// Upsert records a step, keeping the first time it was reached: a second run of the guide does not make an install
++// look newer, and re-reporting a step is harmless.
++func (r *Report) Upsert(s Step) {
++ for i := range r.Steps {
++ if r.Steps[i].Name == s.Name {
++ r.Steps[i] = s
++ return
++ }
++ }
++ r.Steps = append(r.Steps, s)
++}
++
++// Has reports whether the step is already recorded.
++func (r *Report) Has(name string) bool {
++ for _, s := range r.Steps {
++ if s.Name == name {
++ return true
++ }
++ }
++ return false
++}
++
++// ValidStep reports whether name is one of the steps Bot stores.
++func ValidStep(name string) bool {
++ for _, s := range Steps {
++ if s == name {
++ return true
++ }
++ }
++ return false
++}
++
++// ValidEvent is what the running Node accepts from `openvibe-node`: a first-run event for a known step, with the
++// version and OS every one carries. Anything else from the local socket is refused rather than forwarded.
++func ValidEvent(e protocol.Event) bool {
++ if e.Name != EventName || e.Fields == nil {
++ return false
++ }
++ step, _ := e.Fields["step"].(string)
++ if !ValidStep(step) {
++ return false
++ }
++ if _, ok := e.Fields["version"].(string); !ok {
++ return false
++ }
++ if _, ok := e.Fields["os"].(string); !ok {
++ return false
++ }
++ return true
++}
++
++// Info is what the first job found about the machine. It is all counts and names: no path, no user, no address.
++type Info struct {
++ OS string `json:"os"`
++ Arch string `json:"arch"`
++ CPUs int `json:"cpus,omitempty"`
++ MemoryTotal uint64 `json:"memory_total_bytes,omitempty"`
++ MemoryFree uint64 `json:"memory_free_bytes,omitempty"`
++ DiskTotal uint64 `json:"disk_total_bytes,omitempty"`
++ DiskFree uint64 `json:"disk_free_bytes,omitempty"`
++}
++
++// Gather collects the machine report. dir is only used to choose the filesystem whose free space is measured; it is
++// never stored.
++func Gather(dir string) Info {
++ i := Info{OS: runtime.GOOS, Arch: runtime.GOARCH, CPUs: runtime.NumCPU()}
++ i.MemoryTotal, i.MemoryFree = memory()
++ i.DiskTotal, i.DiskFree = disk(dir)
++ return i
++}
++
++// Fields turns the info into the first_job event's fields, with the version and OS every event carries.
++func (i Info) Fields(version string) map[string]any {
++ f := map[string]any{"version": version, "os": i.OS}
++ if i.Arch != "" {
++ f["arch"] = i.Arch
++ }
++ if i.CPUs > 0 {
++ f["cpus"] = i.CPUs
++ }
++ if i.MemoryTotal > 0 {
++ f["memory_total_bytes"] = i.MemoryTotal
++ f["memory_free_bytes"] = i.MemoryFree
++ }
++ if i.DiskTotal > 0 {
++ f["disk_total_bytes"] = i.DiskTotal
++ f["disk_free_bytes"] = i.DiskFree
++ }
++ return f
++}
++
++// Events turns the recorded steps into the telemetry events, in order. Every event names the step, the version and
++// the OS; a failed self-check carries the reason, and the first job carries the machine's counts.
++func (r *Report) Events(version, goos, goarch string) []protocol.Event {
++ var out []protocol.Event
++ for _, name := range Steps {
++ for _, s := range r.Steps {
++ if s.Name != name {
++ continue
++ }
++ f := map[string]any{"step": s.Name, "version": version, "os": goos}
++ if goarch != "" {
++ f["arch"] = goarch
++ }
++ if s.Name == StepCheck {
++ f["ok"] = s.OK
++ if !s.OK && s.Reason != "" {
++ f["reason"] = SafeReason(s.Reason)
++ }
++ }
++ if s.Name == StepFirstJob {
++ for k, v := range s.Fields {
++ f[k] = v
++ }
++ // The step, version and OS are ours, never a field's.
++ f["step"], f["version"], f["os"], f["arch"] = s.Name, version, goos, goarch
++ }
++ at := s.At
++ if at.IsZero() {
++ at = time.Now()
++ }
++ out = append(out, protocol.Event{Name: EventName, TS: at.UnixMilli(), Fields: f})
++ break
++ }
++ }
++ return out
++}
++
++// SafeReason bounds and cleans a self-check reason for an event and a page: one line, no control characters, no
++// path, no address. The checks themselves produce bounded reasons; this is the last gate.
++func SafeReason(reason string) string {
++ reason = strings.Map(func(r rune) rune {
++ if r < 0x20 || r == 0x7f {
++ return ' '
++ }
++ return r
++ }, reason)
++ reason = strings.Join(strings.Fields(reason), " ")
++ reason = scrubAddresses(reason)
++ if len(reason) > 240 {
++ reason = reason[:240]
++ }
++ return strings.TrimSpace(reason)
++}
++
++// scrubAddresses drops anything that looks like an IPv4/IPv6 address or a URL path, so a transport error can never
++// put a machine's address in an event.
++func scrubAddresses(s string) string {
++ fields := strings.Fields(s)
++ out := fields[:0]
++ for _, f := range fields {
++ host := strings.TrimSuffix(strings.Trim(f, "[](),;:"), ".")
++ if h, _, err := net.SplitHostPort(host); err == nil {
++ host = strings.Trim(h, "[]")
++ }
++ if ip := net.ParseIP(host); ip != nil {
++ continue
++ }
++ if strings.Contains(f, "/") && (strings.Contains(f, "://") || strings.HasPrefix(f, "/")) {
++ continue
++ }
++ out = append(out, f)
++ }
++ return strings.Join(out, " ")
++}
++
++// Check answers whether this machine reaches Bot and, for a Network-paired machine, Network: GET <origin>/api/health,
++// a 2xx being up. The reason is safe to store (no address, no path) and short.
++func Check(ctx context.Context, client *http.Client, botURL, networkURL string) (bool, string) {
++ if client == nil {
++ client = &http.Client{Timeout: 8 * time.Second}
++ }
++ checked := 0
++ for _, target := range []struct{ name, url string }{{"OpenVibe.Bot", botURL}, {"OpenVibe.Network", networkURL}} {
++ if strings.TrimSpace(target.url) == "" {
++ continue
++ }
++ checked++
++ if ok, reason := probe(ctx, client, target.name, target.url); !ok {
++ return false, reason
++ }
++ }
++ if checked == 0 {
++ return false, "no OpenVibe address is configured"
++ }
++ return true, ""
++}
++
++func probe(ctx context.Context, client *http.Client, name, origin string) (bool, string) {
++ u := strings.TrimSuffix(strings.TrimSpace(origin), "/") + "/api/health"
++ req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
++ if err != nil {
++ return false, fmt.Sprintf("%s: the configured address is not usable", name)
++ }
++ resp, err := client.Do(req)
++ if err != nil {
++ if ctx.Err() != nil {
++ return false, fmt.Sprintf("%s did not answer in time", name)
++ }
++ var ne net.Error
++ if errors.As(err, &ne) && ne.Timeout() {
++ return false, fmt.Sprintf("%s did not answer in time", name)
++ }
++ return false, fmt.Sprintf("%s is unreachable from this machine", name)
++ }
++ defer resp.Body.Close()
++ if resp.StatusCode/100 != 2 {
++ return false, fmt.Sprintf("%s answered HTTP %d", name, resp.StatusCode)
++ }
++ return true, ""
++}
ddiff --git a/internal/firstrun/firstrun_test.go b/internal/firstrun/firstrun_test.go
nnew file mode 100644
iindex 0000000..89c0ef1
---- /dev/null
++++ b/internal/firstrun/firstrun_test.go
@@@ -0,0 +1,133 @@
++package firstrun
++
++import (
++ "context"
++ "encoding/json"
++ "net/http"
++ "net/http/httptest"
++ "os"
++ "path/filepath"
++ "runtime"
++ "strings"
++ "testing"
++ "time"
++)
++
++func TestReportRoundTrip(t *testing.T) {
++ dir := t.TempDir()
++ path := filepath.Join(dir, FileName)
++ r, err := Load(path)
++ if err != nil || len(r.Steps) != 0 {
++ t.Fatalf("%v %+v", err, r)
++ }
++ at := time.Now().UTC().Truncate(time.Second)
++ r.Upsert(Step{Name: StepInstalled, At: at})
++ r.Upsert(Step{Name: StepPaired, At: at.Add(time.Second)})
++ r.Upsert(Step{Name: StepCheck, At: at.Add(2 * time.Second), OK: true})
++ r.Upsert(Step{Name: StepFirstJob, At: at.Add(3 * time.Second), Fields: Info{OS: "linux", Arch: "amd64", CPUs: 4}.Fields("v1")})
++ // A second upsert replaces the step rather than appending a duplicate.
++ r.Upsert(Step{Name: StepCheck, At: at.Add(4 * time.Second), OK: false, Reason: "OpenVibe.Bot answered HTTP 503"})
++ if err := r.Save(path); err != nil {
++ t.Fatal(err)
++ }
++ if st, _ := os.Stat(path); runtime.GOOS != "windows" && st.Mode().Perm() != 0o600 {
++ t.Fatalf("mode %o", st.Mode().Perm())
++ }
++ got, err := Load(path)
++ if err != nil || len(got.Steps) != 4 {
++ t.Fatalf("%v %+v", err, got)
++ }
++ last := got.Steps[len(got.Steps)-1]
++ if last.Name != StepFirstJob || last.Fields["cpus"] != float64(4) {
++ t.Fatalf("last step %+v (JSON numbers decode as float64)", last)
++ }
++}
++
++func TestEventsCarryOnlyTheStepAndMachineCounts(t *testing.T) {
++ at := time.Unix(1700000000, 0).UTC()
++ r := &Report{Version: 1, Steps: []Step{
++ {Name: StepInstalled, At: at},
++ {Name: StepPaired, At: at.Add(time.Second)},
++ {Name: StepCheck, At: at.Add(2 * time.Second), OK: false, Reason: "OpenVibe.Bot answered HTTP 503"},
++ {Name: StepFirstJob, At: at.Add(3 * time.Second), Fields: Info{OS: runtime.GOOS, Arch: runtime.GOARCH, CPUs: 8,
++ MemoryTotal: 16 << 30, MemoryFree: 8 << 30, DiskTotal: 100 << 30, DiskFree: 40 << 30}.Fields("v9")},
++ }}
++ events := r.Events("v9", runtime.GOOS, runtime.GOARCH)
++ if len(events) != 4 {
++ t.Fatalf("events %d", len(events))
++ }
++ want := []string{"installed", "paired", "self_check", "first_job"}
++ for i, e := range events {
++ if e.Name != EventName || e.Fields["step"] != want[i] || e.Fields["version"] != "v9" || e.Fields["os"] != runtime.GOOS {
++ t.Fatalf("event %d: %+v", i, e)
++ }
++ }
++ if events[2].Fields["ok"] != false || !strings.Contains(events[2].Fields["reason"].(string), "HTTP 503") {
++ t.Fatalf("self-check %+v", events[2])
++ }
++ if events[3].Fields["cpus"] != 8 || events[3].Fields["disk_free_bytes"] != uint64(40<<30) {
++ t.Fatalf("first job %+v", events[3])
++ }
++ // Nothing a person owns goes into an event: no hostname, no account name, no home path, no IPv4 address.
++ b, _ := json.Marshal(events)
++ host, _ := os.Hostname()
++ for _, secret := range []string{host, os.Getenv("USER"), os.Getenv("HOME"), "127.0.0.1", "/home/", "/tmp/"} {
++ if secret != "" && strings.Contains(string(b), secret) {
++ t.Fatalf("%q in events: %s", secret, b)
++ }
++ }
++}
++
++func TestSafeReasonDropsAddressesAndPaths(t *testing.T) {
++ got := SafeReason("dial tcp 10.1.2.3:443: connect: refused; /home/alice/.config\nnext line")
++ if strings.Contains(got, "10.1.2.3") || strings.Contains(got, "/home/") || strings.Contains(got, "\n") {
++ t.Fatalf("unsafe reason %q", got)
++ }
++ long := strings.Repeat("x", 400)
++ if len(SafeReason(long)) != 240 {
++ t.Fatalf("not bounded: %d", len(SafeReason(long)))
++ }
++}
++
++func TestCheck(t *testing.T) {
++ ok := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
++ if r.URL.Path != "/api/health" {
++ http.NotFound(w, r)
++ return
++ }
++ w.WriteHeader(http.StatusOK)
++ }))
++ defer ok.Close()
++ down := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
++ http.Error(w, "no", http.StatusServiceUnavailable)
++ }))
++ defer down.Close()
++
++ if good, reason := Check(context.Background(), ok.Client(), ok.URL, ""); !good || reason != "" {
++ t.Fatalf("%v %q", good, reason)
++ }
++ if good, reason := Check(context.Background(), down.Client(), ok.URL, down.URL); good || !strings.Contains(reason, "OpenVibe.Network answered HTTP 503") {
++ t.Fatalf("%v %q", good, reason)
++ }
++ if good, reason := Check(context.Background(), ok.Client(), "http://127.0.0.1:1", ""); good || strings.Contains(reason, "127.0.0.1") {
++ t.Fatalf("%v %q", good, reason)
++ }
++ if good, reason := Check(context.Background(), ok.Client(), "", ""); good || !strings.Contains(reason, "no OpenVibe address") {
++ t.Fatalf("%v %q", good, reason)
++ }
++}
++
++func TestGatherNeverStoresTheDirectory(t *testing.T) {
++ dir := t.TempDir()
++ i := Gather(dir)
++ if i.OS != runtime.GOOS || i.Arch != runtime.GOARCH || i.CPUs < 1 {
++ t.Fatalf("%+v", i)
++ }
++ if runtime.GOOS == "linux" && (i.MemoryTotal == 0 || i.DiskTotal == 0) {
++ t.Fatalf("linux figures missing: %+v", i)
++ }
++ b, _ := json.Marshal(i)
++ if strings.Contains(string(b), dir) {
++ t.Fatalf("the directory leaked: %s", b)
++ }
++}
ddiff --git a/internal/firstrun/info_darwin.go b/internal/firstrun/info_darwin.go
nnew file mode 100644
iindex 0000000..e925e42
---- /dev/null
++++ b/internal/firstrun/info_darwin.go
@@@ -0,0 +1,17 @@
++//go:build darwin
++
++package firstrun
++
++import "syscall"
++
++// memory is not read on macOS without another API; the event simply carries no memory figures there.
++func memory() (total, free uint64) { return 0, 0 }
++
++// disk measures the filesystem that holds dir, in bytes.
++func disk(dir string) (total, free uint64) {
++ var st syscall.Statfs_t
++ if err := syscall.Statfs(dir, &st); err != nil {
++ return 0, 0
++ }
++ return uint64(st.Blocks) * uint64(st.Bsize), uint64(st.Bavail) * uint64(st.Bsize)
++}
ddiff --git a/internal/firstrun/info_linux.go b/internal/firstrun/info_linux.go
nnew file mode 100644
iindex 0000000..1cbe205
---- /dev/null
++++ b/internal/firstrun/info_linux.go
@@@ -0,0 +1,44 @@
++//go:build linux
++
++package firstrun
++
++import (
++ "os"
++ "strconv"
++ "strings"
++ "syscall"
++)
++
++// memory reads MemTotal and MemAvailable (or MemFree) from /proc/meminfo, in bytes.
++func memory() (total, free uint64) {
++ b, err := os.ReadFile("/proc/meminfo")
++ if err != nil {
++ return 0, 0
++ }
++ for _, line := range strings.Split(string(b), "\n") {
++ key, value, ok := strings.Cut(line, ":")
++ if !ok {
++ continue
++ }
++ n, err := strconv.ParseUint(strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(value), "kB")), 10, 64)
++ if err != nil {
++ continue
++ }
++ switch key {
++ case "MemTotal":
++ total = n * 1024
++ case "MemAvailable":
++ free = n * 1024
++ }
++ }
++ return total, free
++}
++
++// disk measures the filesystem that holds dir, in bytes.
++func disk(dir string) (total, free uint64) {
++ var st syscall.Statfs_t
++ if err := syscall.Statfs(dir, &st); err != nil {
++ return 0, 0
++ }
++ return uint64(st.Blocks) * uint64(st.Bsize), uint64(st.Bavail) * uint64(st.Bsize)
++}
ddiff --git a/internal/firstrun/info_other.go b/internal/firstrun/info_other.go
nnew file mode 100644
iindex 0000000..1d5a46d
---- /dev/null
++++ b/internal/firstrun/info_other.go
@@@ -0,0 +1,7 @@
++//go:build !linux && !darwin
++
++package firstrun
++
++// memory and disk are only read on Linux and macOS; elsewhere the event carries the OS, architecture and CPU count.
++func memory() (total, free uint64) { return 0, 0 }
++func disk(string) (total, free uint64) { return 0, 0 }
ddiff --git a/internal/localctl/localctl.go b/internal/localctl/localctl.go
iindex 78081f6..47f37e2 100644
---- a/internal/localctl/localctl.go
++++ b/internal/localctl/localctl.go
@@@ -28,12 +28,17 @@ const (
// CmdTray: `openvibe-node tray` shows the approvals (On) or has quit; the computer plugin's own desktop prompts
// stay quiet while it does.
CmdTray = "tray"
++ // CmdFirstRun carries the guided first run's telemetry events (Data) to the running Node, which sends them on the
++ // device link: the installer starts the service before the guide runs, and a second device connection would
++ // replace it.
++ CmdFirstRun = "first_run"
)
type Request struct {
-- Cmd string `json:"cmd"`
-- ID string `json:"id,omitempty"` // approve and deny: the locally minted approval nonce (never the cloud command ID)
-- On bool `json:"on,omitempty"` // tray: the tray is running
++ Cmd string `json:"cmd"`
++ ID string `json:"id,omitempty"` // approve and deny: the locally minted approval nonce (never the cloud command ID)
++ On bool `json:"on,omitempty"` // tray: the tray is running
++ Data json.RawMessage `json:"data,omitempty"` // first_run: the telemetry events to report
}
type Response struct {
ddiff --git a/internal/node/firstrun_test.go b/internal/node/firstrun_test.go
nnew file mode 100644
iindex 0000000..2610bf9
---- /dev/null
++++ b/internal/node/firstrun_test.go
@@@ -0,0 +1,53 @@
++//go:build !windows
++
++package node
++
++import (
++ "encoding/json"
++ "testing"
++ "time"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/firstrun"
++ "github.com/OpenVibers/OpenVibe.Node/internal/localctl"
++ "github.com/OpenVibers/OpenVibe.Node/internal/protocol"
++)
++
++// The running Node takes the guide's events on the local socket and sends them on the device link it already has:
++// one telemetry frame per step, so the installer's service is not replaced by a second connection.
++func TestFirstRunEventsGoOnTheExistingLink(t *testing.T) {
++ e := start(t)
++ event := protocol.Event{Name: firstrun.EventName, TS: time.Now().UnixMilli(),
++ Fields: map[string]any{"step": firstrun.StepInstalled, "version": "test", "os": "linux", "arch": "amd64"}}
++ data, _ := json.Marshal(protocol.Telemetry{Events: []protocol.Event{event}})
++ if resp := e.node.control(localctl.Request{Cmd: localctl.CmdFirstRun, Data: data}); !resp.OK {
++ t.Fatalf("%+v", resp)
++ }
++ f, err := e.conn.Expect(protocol.TypeTelemetry, 3*time.Second, func(m protocol.Message) bool {
++ tel, ok := m.(protocol.Telemetry)
++ return ok && len(tel.Events) == 1 && tel.Events[0].Name == firstrun.EventName && tel.Events[0].Fields["step"] == firstrun.StepInstalled
++ })
++ if err != nil {
++ t.Fatal(err)
++ }
++ if f.Msg.(protocol.Telemetry).Events[0].Fields["version"] != "test" {
++ t.Fatalf("%+v", f.Msg)
++ }
++}
++
++func TestFirstRunControlRefusesAnythingElse(t *testing.T) {
++ e := start(t)
++ bad := func(data []byte, why string) {
++ t.Helper()
++ if resp := e.node.control(localctl.Request{Cmd: localctl.CmdFirstRun, Data: data}); resp.OK {
++ t.Fatalf("%s was accepted", why)
++ }
++ }
++ bad(nil, "empty")
++ bad([]byte(`not json`), "bad JSON")
++ other := protocol.Event{Name: "something.else", TS: 1, Fields: map[string]any{"step": firstrun.StepInstalled, "version": "test", "os": "linux"}}
++ b, _ := json.Marshal(protocol.Telemetry{Events: []protocol.Event{other}})
++ bad(b, "another event")
++ unknown := protocol.Event{Name: firstrun.EventName, TS: 1, Fields: map[string]any{"step": "nope", "version": "test", "os": "linux"}}
++ b, _ = json.Marshal(protocol.Telemetry{Events: []protocol.Event{unknown}})
++ bad(b, "an unknown step")
++}
ddiff --git a/internal/node/node.go b/internal/node/node.go
iindex 08d8b53..1f1aa47 100644
---- a/internal/node/node.go
++++ b/internal/node/node.go
@@@ -27,6 +27,7 @@ import (
"github.com/OpenVibers/OpenVibe.Node/internal/config"
"github.com/OpenVibers/OpenVibe.Node/internal/credentials"
++ "github.com/OpenVibers/OpenVibe.Node/internal/firstrun"
"github.com/OpenVibers/OpenVibe.Node/internal/fleet"
"github.com/OpenVibers/OpenVibe.Node/internal/link"
"github.com/OpenVibers/OpenVibe.Node/internal/localctl"
@@@ -1466,6 +1467,32 @@ func (n *Node) control(r localctl.Request) localctl.Response {
case localctl.CmdTray:
n.tellTray(r.On)
return localctl.Response{OK: true}
++ case localctl.CmdFirstRun:
++ // The guided first run's one event per step, sent on this connection (the installer starts the service before
++ // the guide, so a second device connection would replace it). Only a first-run event for a known step goes on.
++ var t protocol.Telemetry
++ if len(r.Data) == 0 || len(r.Data) > 16<<10 {
++ return localctl.Response{Error: "first_run: no events"}
++ }
++ if err := json.Unmarshal(r.Data, &t); err != nil || len(t.Events) == 0 || len(t.Events) > 16 {
++ return localctl.Response{Error: "first_run: the data is not a telemetry frame with events"}
++ }
++ for _, e := range t.Events {
++ if !firstrun.ValidEvent(e) {
++ return localctl.Response{Error: "first_run: only node.first_run events for a known step are accepted"}
++ }
++ }
++ if !n.reporting() {
++ return localctl.Response{Error: "first_run: the node is not connected to OpenVibe.Bot yet"}
++ }
++ // One event per telemetry frame, as the step names say.
++ for _, e := range t.Events {
++ n.mu.Lock()
++ n.events = append(n.events, e)
++ n.mu.Unlock()
++ n.flushTelemetry(true)
++ }
++ return localctl.Response{OK: true}
case localctl.CmdStop:
n.mgr.EstopAll(plugins.EstopLocal, "openvibe-node stop") // at once, then persist
if _, err := n.latch.SetLocalWith("openvibe-node stop"); err != nil {