mirror of
https://github.com/Quad4-Software/Reticulum-Go
synced 2026-08-29 23:48:44 -04:00
527 lines
11 KiB
Go
527 lines
11 KiB
Go
// SPDX-License-Identifier: Apache-2.0
|
|
// Copyright (c) 2024-2026 Quad4.io
|
|
|
|
package rnsutil
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"math"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"quad4/msgpack/v5/pkg/msgpack"
|
|
"quad4/reticulum-go/pkg/destination"
|
|
"quad4/reticulum-go/pkg/identity"
|
|
"quad4/reticulum-go/pkg/link"
|
|
"quad4/reticulum-go/pkg/transport"
|
|
)
|
|
|
|
const (
|
|
// RNXAppName is the destination app name for remote execution.
|
|
RNXAppName = "rnx"
|
|
// RNXAspect is the execute aspect.
|
|
RNXAspect = "execute"
|
|
// RNXCommandPath is the link request path.
|
|
RNXCommandPath = "command"
|
|
// DefaultRNXTimeout is the default path/link/command wait.
|
|
DefaultRNXTimeout = 15 * time.Second
|
|
// RNXRemoteExecGrace matches Python remote_exec_grace.
|
|
RNXRemoteExecGrace = 2 * time.Second
|
|
|
|
// Exit codes matching Python rnx.
|
|
ExitRNXInvalidDest = 241
|
|
ExitRNXPathNotFound = 242
|
|
ExitRNXLinkFailed = 243
|
|
ExitRNXRequestFailed = 244
|
|
ExitRNXNoResult = 245
|
|
ExitRNXReceiveFailed = 246
|
|
ExitRNXInvalidResult = 247
|
|
ExitRNXRemoteExecFail = 248
|
|
ExitRNXNoResponse = 249
|
|
ExitRNXMirrorNilCode = 240
|
|
)
|
|
|
|
// RNXIdentityPath returns the default identity path under storage.
|
|
func RNXIdentityPath(cfgStorage string) string {
|
|
if cfgStorage == "" {
|
|
return ""
|
|
}
|
|
return filepath.Join(cfgStorage, "identities", RNXAppName)
|
|
}
|
|
|
|
// PrepareRNXIdentity loads or creates the rnx identity file.
|
|
func PrepareRNXIdentity(path string) (*identity.Identity, error) {
|
|
if path == "" {
|
|
return nil, fmt.Errorf("empty identity path")
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
return nil, err
|
|
}
|
|
if st, err := os.Stat(path); err == nil && !st.IsDir() {
|
|
return identity.FromFile(path)
|
|
}
|
|
id, err := identity.New()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := id.ToFile(path); err != nil {
|
|
return nil, err
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// LoadRNXAllowedIdentities reads allow-list files (Python rnx + Go rgox paths) and CLI hashes.
|
|
func LoadRNXAllowedIdentities(extra []string) ([][]byte, error) {
|
|
var out [][]byte
|
|
seen := map[string]struct{}{}
|
|
add := func(h []byte) {
|
|
k := hex.EncodeToString(h)
|
|
if _, ok := seen[k]; ok {
|
|
return
|
|
}
|
|
seen[k] = struct{}{}
|
|
out = append(out, h)
|
|
}
|
|
home := os.Getenv("HOME")
|
|
candidates := []string{
|
|
"/etc/rnx/allowed_identities",
|
|
filepath.Join(home, ".config", "rnx", "allowed_identities"),
|
|
filepath.Join(home, ".rnx", "allowed_identities"),
|
|
filepath.Join(home, ".config", "rgox", "allowed_identities"),
|
|
filepath.Join(home, ".rgox", "allowed_identities"),
|
|
}
|
|
for _, path := range candidates {
|
|
hashes, err := readAllowedFile(path)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, h := range hashes {
|
|
add(h)
|
|
}
|
|
}
|
|
for _, a := range extra {
|
|
h, err := ParseDestHash(strings.TrimSpace(a))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("allowed identity %q: %w", a, err)
|
|
}
|
|
add(h)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// EstablishRNXLink waits for a path and opens an outbound link to rnx.execute.
|
|
func EstablishRNXLink(ctx context.Context, tr *transport.Transport, destHash []byte) (*link.Link, error) {
|
|
if err := WaitPathWindow(ctx, tr, destHash); err != nil {
|
|
return nil, fmt.Errorf("path: %w", err)
|
|
}
|
|
remote, err := identity.Recall(destHash)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("recall: %w", err)
|
|
}
|
|
outDest, err := destination.New(remote, destination.Out, destination.Single, RNXAppName, tr, RNXAspect)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
l := link.NewLink(outDest, tr, nil, nil, nil)
|
|
if err := activateOutboundLink(ctx, l); err != nil {
|
|
return nil, fmt.Errorf("link: %w", err)
|
|
}
|
|
return l, nil
|
|
}
|
|
|
|
// RNXRequest is the 5-field command request payload.
|
|
type RNXRequest struct {
|
|
Command string
|
|
TimeoutSec *float64
|
|
StdoutLimit *int
|
|
StderrLimit *int
|
|
Stdin []byte
|
|
}
|
|
|
|
// RNXResult is the 8-field command response payload.
|
|
type RNXResult struct {
|
|
Executed bool
|
|
ReturnCode *int
|
|
Stdout []byte
|
|
Stderr []byte
|
|
StdoutTotal int
|
|
StderrTotal int
|
|
StartedAt float64
|
|
ConcludedAt *float64
|
|
}
|
|
|
|
// PackRNXRequest builds the msgpack-friendly 5-list for link.request.
|
|
func PackRNXRequest(req RNXRequest) []any {
|
|
var timeout any
|
|
if req.TimeoutSec != nil {
|
|
timeout = *req.TimeoutSec
|
|
}
|
|
var oLim, eLim any
|
|
if req.StdoutLimit != nil {
|
|
oLim = *req.StdoutLimit
|
|
}
|
|
if req.StderrLimit != nil {
|
|
eLim = *req.StderrLimit
|
|
}
|
|
var stdin any
|
|
if req.Stdin != nil {
|
|
stdin = req.Stdin
|
|
}
|
|
return []any{
|
|
[]byte(req.Command),
|
|
timeout,
|
|
oLim,
|
|
eLim,
|
|
stdin,
|
|
}
|
|
}
|
|
|
|
// ParseRNXRequestPayload unpacks handler data (msgpack 5-list or already-decoded list).
|
|
func ParseRNXRequestPayload(data []byte) (RNXRequest, error) {
|
|
var req RNXRequest
|
|
if len(data) == 0 {
|
|
return req, fmt.Errorf("empty request")
|
|
}
|
|
var raw any
|
|
if err := msgpack.Unmarshal(data, &raw); err != nil {
|
|
return req, fmt.Errorf("msgpack: %w", err)
|
|
}
|
|
return parseRNXRequestAny(raw)
|
|
}
|
|
|
|
func parseRNXRequestAny(raw any) (RNXRequest, error) {
|
|
var req RNXRequest
|
|
list, ok := raw.([]any)
|
|
if !ok || len(list) < 5 {
|
|
return req, fmt.Errorf("invalid request format")
|
|
}
|
|
switch v := list[0].(type) {
|
|
case []byte:
|
|
req.Command = string(v)
|
|
case string:
|
|
req.Command = v
|
|
default:
|
|
return req, fmt.Errorf("command must be bytes or string")
|
|
}
|
|
if list[1] != nil {
|
|
f := asFloat64(list[1])
|
|
req.TimeoutSec = &f
|
|
}
|
|
if list[2] != nil {
|
|
n, err := asIntAny(list[2])
|
|
if err != nil {
|
|
return req, fmt.Errorf("stdout limit: %w", err)
|
|
}
|
|
req.StdoutLimit = &n
|
|
}
|
|
if list[3] != nil {
|
|
n, err := asIntAny(list[3])
|
|
if err != nil {
|
|
return req, fmt.Errorf("stderr limit: %w", err)
|
|
}
|
|
req.StderrLimit = &n
|
|
}
|
|
if list[4] != nil {
|
|
switch v := list[4].(type) {
|
|
case []byte:
|
|
req.Stdin = v
|
|
case string:
|
|
req.Stdin = []byte(v)
|
|
default:
|
|
return req, fmt.Errorf("stdin must be bytes or string")
|
|
}
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
// ParseRNXResult unpacks a link response value into RNXResult.
|
|
func ParseRNXResult(v any) (RNXResult, error) {
|
|
var out RNXResult
|
|
if v == nil {
|
|
return out, fmt.Errorf("nil response")
|
|
}
|
|
if b, ok := v.([]byte); ok {
|
|
var raw any
|
|
if err := msgpack.Unmarshal(b, &raw); err != nil {
|
|
return out, fmt.Errorf("msgpack: %w", err)
|
|
}
|
|
v = raw
|
|
}
|
|
list, ok := v.([]any)
|
|
if !ok || len(list) < 8 {
|
|
return out, fmt.Errorf("invalid result format")
|
|
}
|
|
switch x := list[0].(type) {
|
|
case bool:
|
|
out.Executed = x
|
|
case int:
|
|
out.Executed = x != 0
|
|
case int64:
|
|
out.Executed = x != 0
|
|
case uint8:
|
|
out.Executed = x != 0
|
|
default:
|
|
return out, fmt.Errorf("executed field invalid")
|
|
}
|
|
if list[1] != nil {
|
|
n, err := asIntAny(list[1])
|
|
if err != nil {
|
|
return out, fmt.Errorf("returncode: %w", err)
|
|
}
|
|
out.ReturnCode = &n
|
|
}
|
|
if list[2] != nil {
|
|
switch x := list[2].(type) {
|
|
case []byte:
|
|
out.Stdout = x
|
|
case string:
|
|
out.Stdout = []byte(x)
|
|
}
|
|
}
|
|
if list[3] != nil {
|
|
switch x := list[3].(type) {
|
|
case []byte:
|
|
out.Stderr = x
|
|
case string:
|
|
out.Stderr = []byte(x)
|
|
}
|
|
}
|
|
if list[4] != nil {
|
|
if n, err := asIntAny(list[4]); err == nil {
|
|
out.StdoutTotal = n
|
|
}
|
|
}
|
|
if list[5] != nil {
|
|
if n, err := asIntAny(list[5]); err == nil {
|
|
out.StderrTotal = n
|
|
}
|
|
}
|
|
if list[6] != nil {
|
|
out.StartedAt = asFloat64(list[6])
|
|
}
|
|
if list[7] != nil {
|
|
f := asFloat64(list[7])
|
|
out.ConcludedAt = &f
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// PackRNXResult builds the 8-list response for the request handler.
|
|
func PackRNXResult(r RNXResult) []any {
|
|
var rc any
|
|
if r.ReturnCode != nil {
|
|
rc = *r.ReturnCode
|
|
}
|
|
var concluded any
|
|
if r.ConcludedAt != nil {
|
|
concluded = *r.ConcludedAt
|
|
}
|
|
var stdout, stderr any
|
|
if r.Stdout != nil {
|
|
stdout = r.Stdout
|
|
}
|
|
if r.Stderr != nil {
|
|
stderr = r.Stderr
|
|
}
|
|
return []any{
|
|
r.Executed,
|
|
rc,
|
|
stdout,
|
|
stderr,
|
|
r.StdoutTotal,
|
|
r.StderrTotal,
|
|
r.StartedAt,
|
|
concluded,
|
|
}
|
|
}
|
|
|
|
// RNXRequestTimeout returns the client request timeout: base + RTT*4 + grace.
|
|
func RNXRequestTimeout(base time.Duration, rttSec float64) time.Duration {
|
|
if base <= 0 {
|
|
base = DefaultRNXTimeout
|
|
}
|
|
return base + time.Duration(rttSec*4*float64(time.Second)) + RNXRemoteExecGrace
|
|
}
|
|
|
|
// SplitShellCommand splits a command string with quote support (shlex-like).
|
|
func SplitShellCommand(command string) ([]string, error) {
|
|
command = strings.TrimSpace(command)
|
|
if command == "" {
|
|
return nil, fmt.Errorf("empty command")
|
|
}
|
|
var (
|
|
args []string
|
|
cur strings.Builder
|
|
inQuote bool
|
|
quoteCh byte
|
|
escaped bool
|
|
)
|
|
for i := 0; i < len(command); i++ {
|
|
c := command[i]
|
|
if escaped {
|
|
cur.WriteByte(c)
|
|
escaped = false
|
|
continue
|
|
}
|
|
if inQuote {
|
|
if c == quoteCh {
|
|
inQuote = false
|
|
continue
|
|
}
|
|
if c == '\\' && quoteCh == '"' {
|
|
escaped = true
|
|
continue
|
|
}
|
|
cur.WriteByte(c)
|
|
continue
|
|
}
|
|
switch c {
|
|
case '"', '\'':
|
|
inQuote = true
|
|
quoteCh = c
|
|
case ' ', '\t':
|
|
if cur.Len() > 0 {
|
|
args = append(args, cur.String())
|
|
cur.Reset()
|
|
}
|
|
default:
|
|
cur.WriteByte(c)
|
|
}
|
|
}
|
|
if inQuote {
|
|
return nil, fmt.Errorf("unclosed quote in command")
|
|
}
|
|
if cur.Len() > 0 {
|
|
args = append(args, cur.String())
|
|
}
|
|
if len(args) == 0 {
|
|
return nil, fmt.Errorf("empty command")
|
|
}
|
|
return args, nil
|
|
}
|
|
|
|
// ExecuteRNXCommandLocally runs a command for an rnx listener and builds RNXResult.
|
|
func ExecuteRNXCommandLocally(req RNXRequest) RNXResult {
|
|
started := float64(time.Now().UnixNano()) / 1e9
|
|
result := RNXResult{StartedAt: started}
|
|
|
|
args, err := SplitShellCommand(req.Command)
|
|
if err != nil {
|
|
return result
|
|
}
|
|
cmd := exec.Command(args[0], args[1:]...) // #nosec G204 -- remote-exec allow-listed operator command
|
|
var stdoutBuf, stderrBuf bytes.Buffer
|
|
cmd.Stdout = &stdoutBuf
|
|
cmd.Stderr = &stderrBuf
|
|
if len(req.Stdin) > 0 {
|
|
cmd.Stdin = bytes.NewReader(req.Stdin)
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
return result
|
|
}
|
|
result.Executed = true
|
|
|
|
done := make(chan error, 1)
|
|
go func() { done <- cmd.Wait() }()
|
|
|
|
var timeout <-chan time.Time
|
|
if req.TimeoutSec != nil && *req.TimeoutSec > 0 {
|
|
timeout = time.After(time.Duration(*req.TimeoutSec * float64(time.Second)))
|
|
}
|
|
|
|
timedOut := false
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
if ee, ok := err.(*exec.ExitError); ok {
|
|
code := ee.ExitCode()
|
|
result.ReturnCode = &code
|
|
} else {
|
|
code := 1
|
|
result.ReturnCode = &code
|
|
}
|
|
} else {
|
|
code := 0
|
|
result.ReturnCode = &code
|
|
}
|
|
case <-timeout:
|
|
timedOut = true
|
|
_ = cmd.Process.Kill()
|
|
<-done
|
|
if cmd.ProcessState != nil {
|
|
code := cmd.ProcessState.ExitCode()
|
|
result.ReturnCode = &code
|
|
}
|
|
}
|
|
|
|
stdout := stdoutBuf.Bytes()
|
|
stderr := stderrBuf.Bytes()
|
|
result.StdoutTotal = len(stdout)
|
|
result.StderrTotal = len(stderr)
|
|
result.Stdout = truncateBytes(stdout, req.StdoutLimit)
|
|
result.Stderr = truncateBytes(stderr, req.StderrLimit)
|
|
if !timedOut {
|
|
concluded := float64(time.Now().UnixNano()) / 1e9
|
|
result.ConcludedAt = &concluded
|
|
}
|
|
return result
|
|
}
|
|
|
|
func truncateBytes(b []byte, limit *int) []byte {
|
|
if b == nil {
|
|
return nil
|
|
}
|
|
if limit == nil {
|
|
return b
|
|
}
|
|
if *limit == 0 {
|
|
return []byte{}
|
|
}
|
|
if len(b) > *limit {
|
|
return b[:*limit]
|
|
}
|
|
return b
|
|
}
|
|
|
|
func asIntAny(v any) (int, error) {
|
|
switch x := v.(type) {
|
|
case int:
|
|
return x, nil
|
|
case int8:
|
|
return int(x), nil
|
|
case int16:
|
|
return int(x), nil
|
|
case int32:
|
|
return int(x), nil
|
|
case int64:
|
|
return int(x), nil
|
|
case uint:
|
|
if x > math.MaxInt {
|
|
return 0, fmt.Errorf("uint value %d overflows int", x)
|
|
}
|
|
return int(x), nil
|
|
case uint8:
|
|
return int(x), nil
|
|
case uint16:
|
|
return int(x), nil
|
|
case uint32:
|
|
return int(x), nil
|
|
case uint64:
|
|
if x > math.MaxInt {
|
|
return 0, fmt.Errorf("uint64 value %d overflows int", x)
|
|
}
|
|
return int(x), nil
|
|
case float64:
|
|
return int(x), nil
|
|
case float32:
|
|
return int(x), nil
|
|
default:
|
|
return 0, fmt.Errorf("not an int: %T", v)
|
|
}
|
|
}
|