mirror of
https://github.com/Quad4-Software/Reticulum-Go
synced 2026-08-29 23:48:44 -04:00
438 lines
13 KiB
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
|
|
}
|