Reticulum-Go/pkg/cli/x.go

438 lines
13 KiB
Go

// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2024-2026 Quad4.io
package cli
import (
"bufio"
"context"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"os/signal"
"strings"
"syscall"
"time"
"quad4/reticulum-go/pkg/destination"
"quad4/reticulum-go/pkg/identity"
"quad4/reticulum-go/pkg/link"
"quad4/reticulum-go/pkg/node"
"quad4/reticulum-go/pkg/rnsutil"
"quad4/reticulum-go/pkg/transport"
)
// RunX implements reticulum-go x / rnx / rgox remote execution.
func RunX(args []string, opt ...Options) int {
stdout, stderr := cliIO(opt)
fs := flag.NewFlagSet("rgox", flag.ContinueOnError)
fs.SetOutput(stderr)
configDir := fs.String("config", "", "path to config directory")
identityPath := fs.String("i", "", "path to identity file")
listenMode := fs.Bool("l", false, "listen for incoming commands")
printID := fs.Bool("p", false, "print identity and destination hash then exit")
interactive := fs.Bool("x", false, "interactive mode")
noAnnounce := fs.Bool("b", false, "don't announce at start (listen)")
noAuth := fs.Bool("n", false, "accept commands from anyone (listen)")
noID := fs.Bool("N", false, "don't identify to listener")
detailed := fs.Bool("d", false, "show detailed result summary")
mirror := fs.Bool("m", false, "mirror remote exit code")
timeoutSec := fs.Float64("w", 0, "path/link/command timeout seconds (0 = adaptive path and link)")
resultTimeoutSec := fs.Float64("W", 0, "max seconds to receive result (0 = unlimited)")
stdinStr := fs.String("stdin", "", "data passed to remote stdin")
stdoutLimit := fs.Int("stdout", -1, "max stdout bytes returned (-1 = unlimited)")
stderrLimit := fs.Int("stderr", -1, "max stderr bytes returned (-1 = unlimited)")
jsonOut := fs.Bool("json", false, "emit JSON result")
var allowed flagStringList
fs.Var(&allowed, "a", "allowed identity hash (repeatable, listen)")
if err := fs.Parse(args); err != nil {
return 2
}
cfg, err := rnsutil.LoadConfigDir(*configDir)
if err != nil {
fmt.Fprintf(stderr, "config: %v\n", err)
return 1
}
idPath := *identityPath
if idPath == "" {
idPath = rnsutil.RNXIdentityPath(rnsutil.StorageDir(cfg))
}
id, err := rnsutil.PrepareRNXIdentity(idPath)
if err != nil {
fmt.Fprintf(stderr, "identity: %v\n", err)
return 2
}
timeout := time.Duration(*timeoutSec * float64(time.Second))
if timeout < 0 {
timeout = 0
}
if *printID {
destHash := destination.Hash(id, rnsutil.RNXAppName, rnsutil.RNXAspect)
fmt.Fprintf(stdout, "%s : %s\n", infoMsg(stdout, "Identity"), rnsutil.PrettyHex(id.Hash()))
fmt.Fprintf(stdout, "%s : %s\n", infoMsg(stdout, "Listening on"), rnsutil.PrettyHex(destHash))
return 0
}
n, err := node.New(cfg)
if err != nil {
fmt.Fprintf(stderr, "node: %v\n", err)
return 1
}
if err := n.Start(); err != nil {
fmt.Fprintf(stderr, "start: %v\n", err)
return 1
}
defer n.Stop()
tr := n.Transport()
if *listenMode {
return runXListen(tr, id, xListenOpts{
allowAll: *noAuth,
noAnnounce: *noAnnounce,
allowedCLI: allowed,
stderr: stderr,
})
}
if fs.NArg() < 1 {
fmt.Fprintln(stderr, "usage: rgox [flags] <destination_hash> [command]")
fmt.Fprintln(stderr, " rgox -l [flags]")
fmt.Fprintln(stderr, " rgox -x [flags] <destination_hash>")
return 2
}
destHash, err := rnsutil.ParseDestHash(fs.Arg(0))
if err != nil {
fmt.Fprintln(stderr, errMsg(stderr, err.Error()))
return rnsutil.ExitRNXInvalidDest
}
var stdoutL, stderrL *int
if *stdoutLimit >= 0 {
stdoutL = stdoutLimit
}
if *stderrLimit >= 0 {
stderrL = stderrLimit
}
var stdin []byte
if *stdinStr != "" {
stdin = []byte(*stdinStr)
}
var timeoutPtr *float64
if timeout > 0 {
s := timeout.Seconds()
timeoutPtr = &s
}
execOpts := xExecOpts{
timeout: timeout,
resultTimeout: time.Duration(*resultTimeoutSec * float64(time.Second)),
noid: *noID,
detailed: *detailed,
mirror: *mirror,
jsonOut: *jsonOut,
stdout: stdout,
stderr: stderr,
req: rnsutil.RNXRequest{
TimeoutSec: timeoutPtr,
StdoutLimit: stdoutL,
StderrLimit: stderrL,
Stdin: stdin,
},
}
if *interactive {
return runXInteractive(tr, id, destHash, &execOpts)
}
if fs.NArg() < 2 {
fmt.Fprintln(stderr, "usage: rgox [flags] <destination_hash> <command>")
return 2
}
execOpts.req.Command = strings.Join(fs.Args()[1:], " ")
return runXExecute(tr, id, destHash, &execOpts)
}
type xListenOpts struct {
allowAll bool
noAnnounce bool
allowedCLI []string
stderr io.Writer
}
func runXListen(tr *transport.Transport, id *identity.Identity, opts xListenOpts) int {
stderr := opts.stderr
if stderr == nil {
stderr = os.Stderr
}
allowed, err := rnsutil.LoadRNXAllowedIdentities(opts.allowedCLI)
if err != nil {
fmt.Fprintf(stderr, "%v\n", err)
return 1
}
if !opts.allowAll && len(allowed) == 0 {
fmt.Fprintln(stderr, warnMsg(stderr, "Warning: No allowed identities configured, rgox will not accept any commands!"))
}
dest, err := destination.New(id, destination.In, destination.Single, rnsutil.RNXAppName, tr, rnsutil.RNXAspect)
if err != nil {
fmt.Fprintf(stderr, "destination: %v\n", err)
return 1
}
dest.AcceptsLinks(true)
dest.SetLinkEstablishedCallback(func(lnk any) {
l, ok := lnk.(*link.Link)
if !ok || l == nil {
return
}
fmt.Fprintln(stderr, infoMsg(stderr, "Command link established"))
l.SetRemoteIdentifiedCallback(func(lnk *link.Link, remote *identity.Identity) {
if opts.allowAll {
return
}
if remote == nil || !rnsutil.AllowedContains(allowed, remote.Hash()) {
fmt.Fprintln(stderr, warnMsg(stderr, "Identity not allowed, tearing down link"))
lnk.Teardown()
}
})
})
allowMode := byte(destination.AllowList)
allowList := allowed
if opts.allowAll {
allowMode = destination.AllowAll
allowList = nil
}
_ = dest.RegisterRequestHandlerAny(rnsutil.RNXCommandPath, func(_ string, data []byte, _ []byte, _ []byte, remote *identity.Identity, _ int64) any {
req, err := rnsutil.ParseRNXRequestPayload(data)
if err != nil {
return rnsutil.PackRNXResult(rnsutil.RNXResult{StartedAt: float64(time.Now().Unix())})
}
who := "unknown"
if remote != nil {
who = rnsutil.PrettyHex(remote.Hash())
}
fmt.Fprintf(stderr, "Executing command [%s] for %s\n", req.Command, who)
res := rnsutil.ExecuteRNXCommandLocally(req)
return rnsutil.PackRNXResult(res)
}, allowMode, allowList)
fmt.Fprintln(stderr, okMsg(stderr, fmt.Sprintf("rgox listening for commands on %s", rnsutil.PrettyHex(dest.GetHash()))))
if !opts.noAnnounce {
_ = dest.Announce(false, nil, nil)
}
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
return 0
}
type xExecOpts struct {
timeout time.Duration
resultTimeout time.Duration
noid bool
detailed bool
mirror bool
jsonOut bool
stdout io.Writer
stderr io.Writer
req rnsutil.RNXRequest
keepLink *link.Link
didIdentify bool
}
func runXInteractive(tr *transport.Transport, id *identity.Identity, destHash []byte, opts *xExecOpts) int {
stdout, stderr := opts.stdout, opts.stderr
ctx, cancel := rnsutil.CLIWaitContext(opts.timeout)
l, err := rnsutil.EstablishRNXLink(ctx, tr, destHash)
cancel()
if err != nil {
if strings.Contains(err.Error(), "path:") {
fmt.Fprintln(stdout, errMsg(stdout, "Path not found"))
return rnsutil.ExitRNXPathNotFound
}
fmt.Fprintln(stdout, errMsg(stdout, "Could not establish link with "+rnsutil.PrettyHex(destHash)))
return rnsutil.ExitRNXLinkFailed
}
defer l.Teardown()
opts.keepLink = l
sc := bufio.NewScanner(os.Stdin)
for {
fmt.Fprint(stderr, "> ")
if !sc.Scan() {
break
}
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
lower := strings.ToLower(line)
if lower == "exit" || lower == "quit" {
return 0
}
if lower == "clear" {
fmt.Fprint(stdout, "\033[2J\033[H")
continue
}
opts.req.Command = line
_ = runXExecute(tr, id, destHash, opts)
}
return 0
}
func runXExecute(tr *transport.Transport, id *identity.Identity, destHash []byte, opts *xExecOpts) int {
stdout, stderr := opts.stdout, opts.stderr
if stdout == nil {
stdout = os.Stdout
}
if stderr == nil {
stderr = os.Stderr
}
var l *link.Link
var err error
reuse := opts.keepLink != nil && opts.keepLink.IsActive()
if reuse {
l = opts.keepLink
} else {
fmt.Fprintln(stdout, infoMsg(stdout, fmt.Sprintf("Path to %s requested", rnsutil.PrettyHex(destHash))))
ctx, cancel := rnsutil.CLIWaitContext(opts.timeout)
l, err = rnsutil.EstablishRNXLink(ctx, tr, destHash)
cancel()
if err != nil {
if strings.Contains(err.Error(), "path:") {
fmt.Fprintln(stdout, errMsg(stdout, "Path not found"))
return rnsutil.ExitRNXPathNotFound
}
fmt.Fprintln(stdout, errMsg(stdout, "Could not establish link with "+rnsutil.PrettyHex(destHash)))
return rnsutil.ExitRNXLinkFailed
}
if opts.keepLink != nil {
opts.keepLink = l
} else {
defer l.Teardown()
}
}
if !opts.noid && !opts.didIdentify {
if err := l.Identify(id); err != nil {
fmt.Fprintf(stderr, "identify: %v\n", err)
return rnsutil.ExitRNXRequestFailed
}
opts.didIdentify = true
}
rexec := rnsutil.RNXRequestTimeout(opts.timeout, l.RTT())
receipt, err := l.Request(rnsutil.RNXCommandPath, rnsutil.PackRNXRequest(opts.req), rexec)
if err != nil {
fmt.Fprintln(stdout, errMsg(stdout, "Could not request remote execution"))
return rnsutil.ExitRNXRequestFailed
}
waitCtx := context.Background()
var cancel context.CancelFunc
if opts.resultTimeout > 0 {
waitCtx, cancel = context.WithTimeout(context.Background(), opts.resultTimeout+rexec)
} else {
waitCtx, cancel = context.WithTimeout(context.Background(), rexec+opts.timeout)
}
defer cancel()
if err := rnsutil.WaitRequest(waitCtx, receipt); err != nil {
if receipt.GetStatus() == link.StatusFailed {
fmt.Fprintln(stdout, errMsg(stdout, "No result was received"))
return rnsutil.ExitRNXNoResult
}
fmt.Fprintln(stdout, errMsg(stdout, "Receiving result failed"))
return rnsutil.ExitRNXReceiveFailed
}
if receipt.GetStatus() == link.StatusFailed {
fmt.Fprintln(stdout, errMsg(stdout, "No result was received"))
return rnsutil.ExitRNXNoResult
}
raw := receipt.GetResponseValue()
if raw == nil {
fmt.Fprintln(stdout, errMsg(stdout, "No response"))
return rnsutil.ExitRNXNoResponse
}
res, err := rnsutil.ParseRNXResult(raw)
if err != nil {
fmt.Fprintln(stdout, errMsg(stdout, "Received invalid result"))
return rnsutil.ExitRNXInvalidResult
}
if opts.jsonOut {
enc := json.NewEncoder(stdout)
enc.SetIndent("", " ")
payload := map[string]any{
"executed": res.Executed,
"returncode": res.ReturnCode,
"stdout": string(res.Stdout),
"stderr": string(res.Stderr),
"stdout_total": res.StdoutTotal,
"stderr_total": res.StderrTotal,
"started_at": res.StartedAt,
"concluded_at": res.ConcludedAt,
"destination": fmt.Sprintf("%x", destHash),
"command": opts.req.Command,
}
_ = enc.Encode(payload)
} else if res.Executed {
if len(res.Stdout) > 0 {
fmt.Fprint(stdout, string(res.Stdout))
}
if len(res.Stderr) > 0 {
fmt.Fprint(stderr, string(res.Stderr))
}
if opts.detailed {
fmt.Fprintln(stdout, "\n--- End of remote output, rgox done ---")
if res.ConcludedAt != nil {
fmt.Fprintf(stdout, "Remote command execution took %.3f seconds\n", *res.ConcludedAt-res.StartedAt)
}
if res.Stdout != nil {
tstr := ""
if len(res.Stdout) < res.StdoutTotal {
tstr = fmt.Sprintf(", %d bytes displayed", len(res.Stdout))
}
fmt.Fprintf(stdout, "Remote wrote %d bytes to stdout%s\n", res.StdoutTotal, tstr)
}
if res.Stderr != nil {
tstr := ""
if len(res.Stderr) < res.StderrTotal {
tstr = fmt.Sprintf(", %d bytes displayed", len(res.Stderr))
}
fmt.Fprintf(stdout, "Remote wrote %d bytes to stderr%s\n", res.StderrTotal, tstr)
}
} else if (opts.req.StdoutLimit != nil && *opts.req.StdoutLimit != 0 && len(res.Stdout) < res.StdoutTotal) ||
(opts.req.StderrLimit != nil && *opts.req.StderrLimit != 0 && len(res.Stderr) < res.StderrTotal) {
fmt.Fprintln(stdout, "\nOutput truncated before being returned:")
if len(res.Stdout) > 0 && len(res.Stdout) < res.StdoutTotal {
fmt.Fprintf(stdout, " stdout truncated to %d bytes\n", len(res.Stdout))
}
if len(res.Stderr) > 0 && len(res.Stderr) < res.StderrTotal {
fmt.Fprintf(stdout, " stderr truncated to %d bytes\n", len(res.Stderr))
}
}
} else {
fmt.Fprintln(stdout, errMsg(stdout, "Remote could not execute command"))
return rnsutil.ExitRNXRemoteExecFail
}
if opts.mirror {
if res.ReturnCode == nil {
return rnsutil.ExitRNXMirrorNilCode
}
return *res.ReturnCode
}
return 0
}