diff --git a/Dockerfile b/Dockerfile index 2cd8d6d..09073aa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,6 +55,12 @@ RUN apt update && apt install -y \ bash-completion curl git tmux tree vim wget xz-utils \ libczmq4 libsodium23 libxml2 libzmq5 python3-pip +RUN curl -fsSL https://pkgs.tailscale.com/stable/debian/bookworm.noarmor.gpg \ + | tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null \ + && curl -fsSL https://pkgs.tailscale.com/stable/debian/bookworm.tailscale-keyring.list \ + | tee /etc/apt/sources.list.d/tailscale.list \ + && apt update && apt install -y tailscale + WORKDIR /root ADD install-node-red.sh . @@ -86,6 +92,12 @@ RUN apt update && apt install -y \ bash-completion curl git mbpoll tmux tree vim wget xz-utils \ build-essential cmake libczmq4 libsodium23 libxml2 libzmq5 python3-dev python3-pip +RUN curl -fsSL https://pkgs.tailscale.com/stable/debian/bookworm.noarmor.gpg \ + | tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null \ + && curl -fsSL https://pkgs.tailscale.com/stable/debian/bookworm.tailscale-keyring.list \ + | tee /etc/apt/sources.list.d/tailscale.list \ + && apt update && apt install -y tailscale + RUN wget -O hivemind.gz https://github.com/DarthSim/hivemind/releases/download/v1.1.0/hivemind-v1.1.0-linux-amd64.gz \ && gunzip --stdout hivemind.gz > /usr/local/bin/hivemind \ && chmod +x /usr/local/bin/hivemind \ diff --git a/src/go/Makefile b/src/go/Makefile index 3b2a434..56484fc 100644 --- a/src/go/Makefile +++ b/src/go/Makefile @@ -14,7 +14,7 @@ MSGBUS_SOURCES := $(shell find msgbus \( -name '*.go' \)) clean: $(RM) bin/* -all: bin/ot-sim-cpu-module bin/ot-sim-logic-module bin/ot-sim-modbus-module bin/ot-sim-mqtt-module bin/ot-sim-node-red-module bin/ot-sim-telnet-module +all: bin/ot-sim-cpu-module bin/ot-sim-logic-module bin/ot-sim-modbus-module bin/ot-sim-mqtt-module bin/ot-sim-node-red-module bin/ot-sim-tailscale-module bin/ot-sim-telnet-module CPU_SOURCES := $(shell find cpu \( -name '*.go' \)) @@ -46,6 +46,12 @@ bin/ot-sim-node-red-module: $(NODERED_SOURCES) mkdir -p bin GOOS=linux go build -a -ldflags="-s -w" -trimpath -o bin/ot-sim-node-red-module cmd/ot-sim-node-red-module/main.go +TAILSCALE_SOURCES := $(shell find tailscale \( -name '*.go' \)) + +bin/ot-sim-tailscale-module: $(TAILSCALE_SOURCES) $(MSGBUS_SOURCES) + mkdir -p bin + GOOS=linux go build -a -ldflags="-s -w" -trimpath -o bin/ot-sim-tailscale-module cmd/ot-sim-tailscale-module/main.go + TELNET_SOURCES := $(shell find telnet \( -name '*.go' \)) bin/ot-sim-telnet-module: $(TELNET_SOURCES) $(MSGBUS_SOURCES) @@ -53,10 +59,11 @@ bin/ot-sim-telnet-module: $(TELNET_SOURCES) $(MSGBUS_SOURCES) GOOS=linux go build -a -ldflags="-s -w" -trimpath -o bin/ot-sim-telnet-module cmd/ot-sim-telnet-module/main.go .PHONY: install -install: bin/ot-sim-cpu-module bin/ot-sim-logic-module bin/ot-sim-modbus-module bin/ot-sim-mqtt-module bin/ot-sim-node-red-module bin/ot-sim-telnet-module - cp bin/ot-sim-cpu-module $(prefix)/bin/ot-sim-cpu-module - cp bin/ot-sim-logic-module $(prefix)/bin/ot-sim-logic-module - cp bin/ot-sim-modbus-module $(prefix)/bin/ot-sim-modbus-module - cp bin/ot-sim-mqtt-module $(prefix)/bin/ot-sim-mqtt-module - cp bin/ot-sim-node-red-module $(prefix)/bin/ot-sim-node-red-module - cp bin/ot-sim-telnet-module $(prefix)/bin/ot-sim-telnet-module +install: bin/ot-sim-cpu-module bin/ot-sim-logic-module bin/ot-sim-modbus-module bin/ot-sim-mqtt-module bin/ot-sim-node-red-module bin/ot-sim-tailscale-module bin/ot-sim-telnet-module + cp bin/ot-sim-cpu-module $(prefix)/bin/ot-sim-cpu-module + cp bin/ot-sim-logic-module $(prefix)/bin/ot-sim-logic-module + cp bin/ot-sim-modbus-module $(prefix)/bin/ot-sim-modbus-module + cp bin/ot-sim-mqtt-module $(prefix)/bin/ot-sim-mqtt-module + cp bin/ot-sim-node-red-module $(prefix)/bin/ot-sim-node-red-module + cp bin/ot-sim-tailscale-module $(prefix)/bin/ot-sim-tailscale-module + cp bin/ot-sim-telnet-module $(prefix)/bin/ot-sim-telnet-module diff --git a/src/go/cmd/ot-sim-tailscale-module/main.go b/src/go/cmd/ot-sim-tailscale-module/main.go new file mode 100644 index 0000000..61afac4 --- /dev/null +++ b/src/go/cmd/ot-sim-tailscale-module/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + + otsim "github.com/patsec/ot-sim" + "github.com/patsec/ot-sim/util" + "github.com/patsec/ot-sim/util/sigterm" + + // This will cause the Tailscale module to register itself with the otsim + // package so it gets run by the otsim.Start function below. + _ "github.com/patsec/ot-sim/tailscale" +) + +func main() { + if len(os.Args) != 2 { + panic("path to config file not provided") + } + + if err := otsim.ParseConfigFile(os.Args[1]); err != nil { + fmt.Printf("Error parsing config file: %v\n", err) + os.Exit(util.ExitNoRestart) + } + + ctx := sigterm.CancelContext(context.Background()) + + if err := otsim.Start(ctx); err != nil { + fmt.Printf("Error starting Tailscale module: %v\n", err) + + var exitErr util.ExitError + if errors.As(err, &exitErr) { + os.Exit(exitErr.ExitCode) + } + + os.Exit(1) + } + + <-ctx.Done() + + if err := ctx.Err(); err != nil && !errors.Is(err, context.Canceled) { + fmt.Printf("Error running Tailscale module: %v\n", err) + + var exitErr util.ExitError + if errors.As(err, &exitErr) { + os.Exit(exitErr.ExitCode) + } + + os.Exit(1) + } +} diff --git a/src/go/tailscale/tailscale.go b/src/go/tailscale/tailscale.go new file mode 100644 index 0000000..d353ff1 --- /dev/null +++ b/src/go/tailscale/tailscale.go @@ -0,0 +1,301 @@ +// Package tailscale implements a Tailscale client as an OT-sim module. +package tailscale + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "strconv" + "syscall" + "time" + + otsim "github.com/patsec/ot-sim" + "github.com/patsec/ot-sim/msgbus" + "github.com/patsec/ot-sim/util" + + "github.com/beevik/etree" +) + +func init() { + otsim.AddModuleFactory("tailscale", new(Factory)) +} + +type Factory struct{} + +func (Factory) NewModule(e *etree.Element) (otsim.Module, error) { + name := e.SelectAttrValue("name", "tailscale") + return New(name), nil +} + +type Tailscale struct { + name string + + authKey string + hostname string + dns bool + + pullEndpoint string + pusher *msgbus.Pusher +} + +func New(name string) *Tailscale { + return &Tailscale{ + name: name, + } +} + +func (this Tailscale) Name() string { + return this.name +} + +func (this *Tailscale) Configure(e *etree.Element) error { + for _, child := range e.ChildElements() { + switch child.Tag { + case "pull-endpoint": + this.pullEndpoint = child.Text() + case "auth-key": + this.authKey = child.Text() + case "hostname": + this.hostname = child.Text() + case "accept-dns": + this.dns, _ = strconv.ParseBool(child.Text()) + } + } + + if this.authKey == "" { + this.authKey = os.Getenv("OTSIM_TAILSCALE_AUTHKEY") + + if this.authKey == "" { + return fmt.Errorf("no Tailscale auth key provided") + } + } + + if this.hostname == "" { + var err error + + this.hostname, err = os.Hostname() + if err != nil { + return fmt.Errorf("unable to set hostname: %w", err) + } + } + + return nil +} + +func (this *Tailscale) Run(ctx context.Context, _, pullEndpoint string) error { + // Use ZeroMQ PULL endpoint specified in `tailscale` config block if provided. + if this.pullEndpoint != "" { + pullEndpoint = this.pullEndpoint + } + + this.pusher = msgbus.MustNewPusher(pullEndpoint) + + if err := this.start(ctx); err != nil { + return fmt.Errorf("starting Tailscale daemon: %w", err) + } + + if err := this.up(ctx); err != nil { + return fmt.Errorf("bringing Tailscale up: %w", err) + } + + go this.status(ctx) + + return nil +} + +func (this Tailscale) log(format string, a ...any) { + fmt.Printf("[%s] %s\n", this.name, fmt.Sprintf(format, a...)) +} + +func (this Tailscale) start(ctx context.Context) error { + exePath, err := exec.LookPath("tailscaled") + if err != nil { + return util.NewExitError(util.ExitNoRestart, "module executable does not exist at tailscaled") + } + + args := []string{ + "--socket=/tmp/tailscaled.sock", + "--state=mem:", "--statedir=/tmp", + } + + otsim.Waiter.Add(1) + + go func() { + defer otsim.Waiter.Done() + + for { + // Not using `exec.CommandContext` here since we're catching the context + // being canceled below in order to gracefully terminate the child + // process. Using `exec.CommandContext` forcefully kills the child process + // when the context is canceled. + cmd := exec.Command(exePath, args...) + + stdout, _ := cmd.StdoutPipe() + stderr, _ := cmd.StderrPipe() + + go func() { + scanner := bufio.NewScanner(stdout) + scanner.Split(bufio.ScanLines) + + for scanner.Scan() { + this.log(scanner.Text()) + } + }() + + go func() { + scanner := bufio.NewScanner(stderr) + scanner.Split(bufio.ScanLines) + + for scanner.Scan() { + this.log("[ERROR] %s", scanner.Text()) + } + }() + + this.log("starting Tailscale daemon") + + if err := cmd.Start(); err != nil { + this.log("[ERROR] starting Tailscale daemon: %v", err) + return + } + + wait := make(chan error) + + go func() { + err := cmd.Wait() + wait <- err + }() + + select { + case err := <-wait: + this.log("[ERROR] Tailscale daemon died (%v)... restarting", err) + continue + case <-ctx.Done(): + this.log("stopping Tailscale daemon") + cmd.Process.Signal(syscall.SIGTERM) + + select { + case <-wait: // SIGTERM *should* cause cmd to exit + this.log("Tailscale daemon has stopped") + return + case <-time.After(10 * time.Second): + this.log("forcefully killing Tailscale daemon") + cmd.Process.Kill() + + return + } + } + } + }() + + this.log("waiting for Tailscale socket") + + for { + if ctx.Err() != nil { + return util.NewExitError(util.ExitNoRestart, "timed out waiting for Tailscale socket") + } + + if _, err := os.Stat("/tmp/tailscaled.sock"); err != nil { + if errors.Is(err, fs.ErrNotExist) { + time.Sleep(100 * time.Millisecond) + continue + } else { + return util.NewExitError(util.ExitNoRestart, "waiting for Tailscale socket: %v", err) + } + } + + break + } + + return nil +} + +func (this Tailscale) up(ctx context.Context) error { + exePath, err := exec.LookPath("tailscale") + if err != nil { + return util.NewExitError(util.ExitNoRestart, "module executable does not exist at tailscale") + } + + args := []string{ + "--socket=/tmp/tailscaled.sock", "up", + "--authkey=" + this.authKey, + "--hostname=" + this.hostname, + "--accept-dns=" + strconv.FormatBool(this.dns), + } + + this.log("initializing Tailscale") + + cmd := exec.CommandContext(ctx, exePath, args...) + + stdout, _ := cmd.StdoutPipe() + stderr, _ := cmd.StderrPipe() + + go func() { + scanner := bufio.NewScanner(stdout) + scanner.Split(bufio.ScanLines) + + for scanner.Scan() { + this.log(scanner.Text()) + } + }() + + go func() { + scanner := bufio.NewScanner(stderr) + scanner.Split(bufio.ScanLines) + + for scanner.Scan() { + this.log("[ERROR] %s", scanner.Text()) + } + }() + + if err := cmd.Run(); err != nil { + return fmt.Errorf("tailscale up failed: %v", err) + } + + return nil +} + +func (this Tailscale) status(ctx context.Context) { + exePath, err := exec.LookPath("tailscale") + if err != nil { + return + } + + args := []string{"--socket=/tmp/tailscaled.sock", "status"} + + for { + select { + case <-ctx.Done(): + return + case <-time.After(5 * time.Second): + point := msgbus.Point{ + Tag: fmt.Sprintf("%s.connected", this.name), + Tstamp: uint64(time.Now().Unix()), + } + + cmd := exec.CommandContext(ctx, exePath, args...) + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + + if err := cmd.Run(); err == nil { + point.Value = 1.0 + } else { + point.Value = 0.0 + } + + env, err := msgbus.NewEnvelope(this.name, msgbus.Status{Measurements: []msgbus.Point{point}}) + if err != nil { + this.log("[ERROR] creating status message: %v", err) + continue + } + + if err := this.pusher.Push("RUNTIME", env); err != nil { + this.log("[ERROR] sending status message: %v", err) + } + } + } +}