openvibers/openvibe.link Commits
node: a guided first run that reports each step (#105)
Changes
@@@ -30,6 +30,7 @@ serves it at `https://<name>--<you>.openvibe.website` behind OpenVibe sign-in, aup with the service ([docs/connect.md](docs/connect.md)).```sh++openvibe-node # guided first run: pair, check the link, report this machine's first jobopenvibe-node status # link, plugins, video, battery, stop latchopenvibe-node stop # kill switch: stop every motor now and keep it stoppedopenvibe-node resumennew file mode 100644@@@ -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])++}nnew file mode 100644@@@ -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)++ }++ }++}@@@ -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}@@@ -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 videofrom 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@@@ -1087,3 +1087,4 @@ print(" camera %s%s" % (v.get("state") or "off", (" (" + v["last_error"] + ")fisay "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)"@@@ -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 {@@@ -58,9 +58,10 @@ type Server struct {credInURL boolbinds 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@@@ -34,6 +34,7 @@ type Server struct {bot *fakebot.Servermu sync.Mutex++ health int // GET /api/health status; 200 unless a test sets itcodes map[string]bool // normalized code → livecredential string // the node credential, once pairedrevoked 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()nnew file mode 100644@@@ -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, ""++}nnew file mode 100644@@@ -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)++ }++}nnew file mode 100644@@@ -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)++}nnew file mode 100644@@@ -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)++}nnew file mode 100644@@@ -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 }@@@ -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 {nnew file mode 100644@@@ -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")++}@@@ -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 persistif _, err := n.latch.SetLocalWith("openvibe-node stop"); err != nil {
@@ -30,6 +30,7 @@ serves it at `https://<name>--<you>.openvibe.website` behind OpenVibe sign-in, a
@@ -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)).
up with the service ([docs/connect.md](docs/connect.md)).
```sh
```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 status # link, plugins, video, battery, stop latch
openvibe-node stop # kill switch: stop every motor now and keep it stopped
openvibe-node stop # kill switch: stop every motor now and keep it stopped
openvibe-node resume
openvibe-node resume
new file mode 100644
new file mode 100644
@@ -0,0 +1,463 @@
@@ -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])
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,196 @@
@@ -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)
+ }
+ }
+}
@@ -1,5 +1,6 @@
@@ -1,5 +1,6 @@
// 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 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
// 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)
// (--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
@@ -36,6 +37,7 @@ import (
@@ -36,6 +37,7 @@ import (
"github.com/OpenVibers/OpenVibe.Node/internal/config"
"github.com/OpenVibers/OpenVibe.Node/internal/config"
"github.com/OpenVibers/OpenVibe.Node/internal/connect"
"github.com/OpenVibers/OpenVibe.Node/internal/connect"
"github.com/OpenVibers/OpenVibe.Node/internal/credentials"
"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/link"
"github.com/OpenVibers/OpenVibe.Node/internal/localctl"
"github.com/OpenVibers/OpenVibe.Node/internal/localctl"
"github.com/OpenVibers/OpenVibe.Node/internal/node"
"github.com/OpenVibers/OpenVibe.Node/internal/node"
@@ -52,6 +54,9 @@ var version = "dev"
@@ -52,6 +54,9 @@ var version = "dev"
const usage = `openvibe-node %s: connect this device to OpenVibe.
const usage = `openvibe-node %s: connect this device to OpenVibe.
Usage:
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 <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 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
@@ -106,10 +111,35 @@ func run(args []string, stdout, stderr io.Writer) int {
@@ -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`.
// 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 {
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)
fmt.Fprintf(stdout, usage, version)
return 0
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:]
cmd, rest := args[0], args[1:]
if cmd == "memory" {
if cmd == "memory" {
// The memory scan has its own flags, so it parses its own arguments.
// 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 {
@@ -652,8 +682,13 @@ func cmdInstall(g *globals, user string, stdout io.Writer) error {
return fmt.Errorf("installed, but could not start: %w", err)
return fmt.Errorf("installed, but could not start: %w", err)
}
}
fmt.Fprintln(stdout, "Installed and started the openvibe-node service.")
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 {
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
return nil
}
}
@@ -111,6 +111,28 @@ Requirements: `python3` with `venv` (`sudo apt install python3 python3-venv`), `
@@ -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
`rpicam-apps` (preinstalled on Raspberry Pi OS). For Cozmo's voice, `espeak-ng`. `ffmpeg` is optional (better video
from plugins that send JPEG frames).
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
## By hand
```sh
```sh
@@ -1087,3 +1087,4 @@ print(" camera %s%s" % (v.get("state") or "off", (" (" + v["last_error"] + ")
@@ -1087,3 +1087,4 @@ print(" camera %s%s" % (v.get("state") or "off", (" (" + v["last_error"] + ")
fi
fi
say "done. Check it with: sudo openvibe-node status Stop everything with: sudo openvibe-node stop"
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)"
@@ -47,6 +47,7 @@ func (p Paths) SocketPath() string { return filepath.Join(p.StateDir, "node.
@@ -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) PluginsDir() string { return filepath.Join(p.StateDir, "plugins") }
func (p Paths) VenvDir() string { return filepath.Join(p.StateDir, "venv") }
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) 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.
// Ensure creates the directories with owner-only permissions.
func (p Paths) Ensure() error {
func (p Paths) Ensure() error {
@@ -58,9 +58,10 @@ type Server struct {
@@ -58,9 +58,10 @@ type Server struct {
credInURL bool
credInURL bool
binds atomic.Int64
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 {
type refusal struct {
@@ -71,9 +72,13 @@ type refusal struct {
@@ -71,9 +72,13 @@ type refusal struct {
// RobotID is the robot AddCode and AddCredential attach devices to.
// RobotID is the robot AddCode and AddCredential attach devices to.
const RobotID = "rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X"
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.
// New starts the fake server.
func New() *Server {
func New() *Server {
s := &Server{codes: map[string]string{}, creds: map[string]string{}, robots: map[string]string{}, live: map[string]*Conn{}}
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.estop = map[string]bool{}
s.conns = make(chan *Conn, 16)
s.conns = make(chan *Conn, 16)
s.Config = protocol.Config{HeartbeatMS: 1000, Limits: protocol.Limits{MaxCommandMS: 1000, HeartbeatMS: 1000}}
s.Config = protocol.Config{HeartbeatMS: 1000, Limits: protocol.Limits{MaxCommandMS: 1000, HeartbeatMS: 1000}}
@@ -82,6 +87,12 @@ func New() *Server {
@@ -82,6 +87,12 @@ func New() *Server {
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(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) {
mux.HandleFunc("/whip/", func(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
s.mu.Lock()
h := s.WHIP
h := s.WHIP
@@ -34,6 +34,7 @@ type Server struct {
@@ -34,6 +34,7 @@ type Server struct {
bot *fakebot.Server
bot *fakebot.Server
mu sync.Mutex
mu sync.Mutex
+ health int // GET /api/health status; 200 unless a test sets it
codes map[string]bool // normalized code → live
codes map[string]bool // normalized code → live
credential string // the node credential, once paired
credential string // the node credential, once paired
revoked bool
revoked bool
@@ -44,10 +45,18 @@ type Server struct {
@@ -44,10 +45,18 @@ type Server struct {
// New starts the fake Network; bot (may be nil) learns every token it mints.
// New starts the fake Network; bot (may be nil) learns every token it mints.
func New(bot *fakebot.Server) *Server {
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 := http.NewServeMux()
mux.HandleFunc(protocol.NodePairingPath, s.pair)
mux.HandleFunc(protocol.NodePairingPath, s.pair)
mux.HandleFunc(protocol.TokenPath, s.token)
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)
s.HTTP = httptest.NewServer(mux)
return s
return s
}
}
@@ -55,6 +64,13 @@ func New(bot *fakebot.Server) *Server {
@@ -55,6 +64,13 @@ func New(bot *fakebot.Server) *Server {
func (s *Server) Close() { s.HTTP.Close() }
func (s *Server) Close() { s.HTTP.Close() }
func (s *Server) URL() string { return s.HTTP.URL }
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.
// AddCode makes a one-time code (normalized, without the dash) live for PairingID.
func (s *Server) AddCode(code string) {
func (s *Server) AddCode(code string) {
s.mu.Lock()
s.mu.Lock()
new file mode 100644
new file mode 100644
@@ -0,0 +1,312 @@
@@ -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, ""
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,133 @@
@@ -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)
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -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)
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -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)
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -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 }
@@ -28,12 +28,17 @@ const (
@@ -28,12 +28,17 @@ const (
// CmdTray: `openvibe-node tray` shows the approvals (On) or has quit; the computer plugin's own desktop prompts
// CmdTray: `openvibe-node tray` shows the approvals (On) or has quit; the computer plugin's own desktop prompts
// stay quiet while it does.
// stay quiet while it does.
CmdTray = "tray"
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 {
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 {
type Response struct {
new file mode 100644
new file mode 100644
@@ -0,0 +1,53 @@
@@ -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")
+}
@@ -27,6 +27,7 @@ import (
@@ -27,6 +27,7 @@ import (
"github.com/OpenVibers/OpenVibe.Node/internal/config"
"github.com/OpenVibers/OpenVibe.Node/internal/config"
"github.com/OpenVibers/OpenVibe.Node/internal/credentials"
"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/fleet"
"github.com/OpenVibers/OpenVibe.Node/internal/link"
"github.com/OpenVibers/OpenVibe.Node/internal/link"
"github.com/OpenVibers/OpenVibe.Node/internal/localctl"
"github.com/OpenVibers/OpenVibe.Node/internal/localctl"
@@ -1466,6 +1467,32 @@ func (n *Node) control(r localctl.Request) localctl.Response {
@@ -1466,6 +1467,32 @@ func (n *Node) control(r localctl.Request) localctl.Response {
case localctl.CmdTray:
case localctl.CmdTray:
n.tellTray(r.On)
n.tellTray(r.On)
return localctl.Response{OK: true}
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:
case localctl.CmdStop:
n.mgr.EstopAll(plugins.EstopLocal, "openvibe-node stop") // at once, then persist
n.mgr.EstopAll(plugins.EstopLocal, "openvibe-node stop") // at once, then persist
if _, err := n.latch.SetLocalWith("openvibe-node stop"); err != nil {
if _, err := n.latch.SetLocalWith("openvibe-node stop"); err != nil {