Reticulum-Go/pkg/cli/sh.go

689 lines
17 KiB
Go

// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2024-2026 Quad4.io
package cli
import (
"flag"
"fmt"
"io"
"os"
"os/signal"
"sync"
"sync/atomic"
"syscall"
"time"
"golang.org/x/term"
"quad4/reticulum-go/pkg/debug"
"quad4/reticulum-go/pkg/destination"
"quad4/reticulum-go/pkg/identity"
"quad4/reticulum-go/pkg/link"
"quad4/reticulum-go/pkg/node"
"quad4/reticulum-go/pkg/rgosh"
"quad4/reticulum-go/pkg/rnsutil"
"quad4/reticulum-go/pkg/transport"
)
// RunSH implements reticulum-go sh / rgosh remote shell.
func RunSH(args []string, opt ...Options) int {
stdout, stderr := cliIO(opt)
fs := flag.NewFlagSet("rgosh", flag.ContinueOnError)
fs.SetOutput(stderr)
configDir := fs.String("config", "", "path to config directory")
identityPath := fs.String("i", "", "path to identity file")
service := fs.String("s", "", "service name (identity file suffix)")
listenMode := fs.Bool("l", false, "listen for incoming shell sessions")
printID := fs.Bool("p", false, "print identity and destination hash then exit")
announceSec := fs.Float64("b", rgosh.DefaultAnnounceSec, "listen announce interval seconds (0 once, default 900)")
noAnnounce := fs.Bool("no-announce", false, "never announce (listen)")
noAuth := fs.Bool("n", false, "accept sessions from anyone (listen)")
noID := fs.Bool("N", false, "don't identify to listener")
mirror := fs.Bool("m", false, "mirror remote exit code")
forced := fs.Bool("C", false, "reject remote cmdline and run the default command only")
remoteAsArgs := fs.Bool("A", false, "append remote cmdline to the default command")
compat := fs.Bool("compat", false, "force Python rnsh wire protocol (auto-detected from destination hash when omitted)")
lineMode := fs.Bool("line", false, "force line-buffered stdin")
rawMode := fs.Bool("raw", false, "disable stdin coalescing")
verbose := fs.Bool("v", false, "enable reticulum debug logs on stderr")
timeoutSec := fs.Float64("w", 0, "path/link timeout seconds (0 = adaptive from interface bitrate)")
var allowed flagStringList
fs.Var(&allowed, "a", "allowed identity hash (repeatable, listen)")
if err := fs.Parse(args); err != nil {
return 2
}
// Interactive shells share stderr with the remote TTY. Keep transport
// chatter off unless -v is set.
if !*verbose {
debug.SetDebugLevel(debug.DebugCritical)
}
cfg, err := rnsutil.LoadConfigDir(*configDir)
if err != nil {
fmt.Fprintf(stderr, "config: %v\n", err)
return 1
}
appName := rnsutil.RgoshAppNameForMode(*compat)
idPath := *identityPath
if idPath == "" {
base := rnsutil.StorageDir(cfg)
if *compat {
idPath = rnsutil.RnshIdentityPath(base)
} else {
idPath = rnsutil.RgoshIdentityPath(base)
}
if *service != "" {
idPath = idPath + "." + *service
}
}
id, err := rnsutil.PrepareRgoshIdentity(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 {
fmt.Fprintf(stdout, "%s : %s\n", infoMsg(stdout, "Identity"), rnsutil.PrettyHex(id.Hash()))
if *compat {
fmt.Fprintf(stdout, "%s : %s\n", infoMsg(stdout, "Listening on"), rnsutil.PrettyHex(destination.Hash(id, appName)))
return 0
}
fmt.Fprintf(stdout, "%s : %s\n", infoMsg(stdout, "Listening on"), rnsutil.PrettyHex(destination.Hash(id, rnsutil.RgoshAppName)))
fmt.Fprintf(stdout, "%s : %s\n", infoMsg(stdout, "Listening on (rnsh)"), rnsutil.PrettyHex(destination.Hash(id, rnsutil.RnshAppName)))
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()
defaultCmd := rgosh.DefaultShell()
if fs.NArg() > 0 && *listenMode {
defaultCmd = append([]string(nil), fs.Args()...)
}
if *listenMode {
return runSHListen(tr, id, shListenOpts{
allowAll: *noAuth,
noAnnounce: *noAnnounce,
announceSec: *announceSec,
allowedCLI: allowed,
compatForced: *compat,
forced: *forced,
remoteAsArgs: *remoteAsArgs,
defaultCmd: defaultCmd,
appName: appName,
stderr: stderr,
})
}
if fs.NArg() < 1 {
fmt.Fprintln(stderr, "usage: rgosh [flags] <destination_hash> [command...]")
fmt.Fprintln(stderr, " rgosh -l [flags] [command...]")
return 2
}
destHash, err := rnsutil.ParseDestHash(fs.Arg(0))
if err != nil {
fmt.Fprintln(stderr, errMsg(stderr, err.Error()))
return 2
}
cmdline := fs.Args()[1:]
return runSHClient(tr, id, destHash, shClientOpts{
timeout: timeout,
noid: *noID,
mirror: *mirror,
compatForced: *compat,
lineMode: *lineMode,
rawMode: *rawMode,
cmdline: cmdline,
appName: appName,
stdout: stdout,
stderr: stderr,
})
}
type shListenOpts struct {
allowAll bool
noAnnounce bool
announceSec float64
allowedCLI []string
compatForced bool
forced bool
remoteAsArgs bool
defaultCmd []string
appName string
stderr io.Writer
}
func runSHListen(tr *transport.Transport, id *identity.Identity, opts shListenOpts) int {
stderr := opts.stderr
if stderr == nil {
stderr = os.Stderr
}
allowed, err := rnsutil.LoadRgoshAllowedIdentities(opts.allowedCLI)
if err != nil {
fmt.Fprintf(stderr, "%v\n", err)
return 1
}
if opts.allowAll {
fmt.Fprintln(stderr, warnMsg(stderr, "Warning: accepting shell sessions from anyone (-n)"))
} else if len(allowed) == 0 {
fmt.Fprintln(stderr, warnMsg(stderr, "Warning: No allowed identities configured, rgosh will not accept sessions"))
}
apps := []string{opts.appName}
if !opts.compatForced {
apps = []string{rnsutil.RgoshAppName, rnsutil.RnshAppName}
}
var announceStops []chan struct{}
for _, appName := range apps {
baseCfg := rgosh.Config{
Compat: appName == rnsutil.RnshAppName,
AllowAll: opts.allowAll,
DefaultCmd: opts.defaultCmd,
ForcedCommand: opts.forced,
RemoteCmdAsArgs: opts.remoteAsArgs,
Listener: true,
}
dest, err := destination.New(id, destination.In, destination.Single, appName, tr)
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
}
allowedNow, loadErr := rnsutil.LoadRgoshAllowedIdentities(opts.allowedCLI)
if loadErr != nil {
fmt.Fprintf(stderr, "%v\n", loadErr)
l.Teardown()
return
}
cfg := baseCfg.Copy()
cfg.Allowed = allowedNow
fmt.Fprintln(stderr, infoMsg(stderr, "Shell link established"))
go serveSHLink(l, cfg, stderr)
})
label := "rgosh"
if appName == rnsutil.RnshAppName {
label = "rgosh (rnsh)"
}
fmt.Fprintln(stderr, okMsg(stderr, fmt.Sprintf("%s listening on %s", label, rnsutil.PrettyHex(dest.GetHash()))))
if !opts.noAnnounce {
_ = dest.Announce(false, nil, nil)
if opts.announceSec > 0 {
stop := make(chan struct{})
announceStops = append(announceStops, stop)
go func(d *destination.Destination, interval float64, halt chan struct{}) {
t := time.NewTicker(time.Duration(interval * float64(time.Second)))
defer t.Stop()
for {
select {
case <-halt:
return
case <-t.C:
_ = d.Announce(false, nil, nil)
}
}
}(dest, opts.announceSec, stop)
}
}
}
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
for _, stop := range announceStops {
close(stop)
}
return 0
}
func serveSHLink(l *link.Link, baseCfg rgosh.Config, stderr io.Writer) {
ch := l.GetChannel()
if baseCfg.Compat {
_ = rgosh.RegisterCompat(ch)
} else {
_ = rgosh.RegisterNative(ch)
}
sess := rgosh.NewSession(baseCfg, rgosh.ChannelSender{Ch: ch})
sess.StartProcess = rgosh.StartLocalProcess
sess.OnTeardown = func() {
sess.Close()
l.Teardown()
}
sess.OnAuthDenied = func(reason string) {
fmt.Fprintln(stderr, warnMsg(stderr, "Identity not allowed: "+reason))
}
ch.AddMessageHandler(func(msg rgosh.Message) bool {
_ = sess.HandleMessage(msg)
return true
})
l.SetLinkClosedCallback(func(*link.Link) {
sess.Close()
})
l.SetRemoteIdentifiedCallback(func(lnk *link.Link, remote *identity.Identity) {
if remote == nil {
return
}
if !sess.SetRemoteIdentity(remote.Hash()) {
lnk.Teardown()
}
})
if remote := l.GetRemoteIdentity(); remote != nil {
if !sess.SetRemoteIdentity(remote.Hash()) {
l.Teardown()
}
}
}
type shClientOpts struct {
timeout time.Duration
noid bool
mirror bool
compatForced bool
lineMode bool
rawMode bool
cmdline []string
appName string
stdout io.Writer
stderr io.Writer
}
func runSHClient(tr *transport.Transport, id *identity.Identity, destHash []byte, opts shClientOpts) int {
stdout, stderr := opts.stdout, opts.stderr
ctx, cancel := rnsutil.CLIWaitContext(opts.timeout)
defer cancel()
compat := opts.compatForced
appName := opts.appName
if !opts.compatForced {
if err := rnsutil.WaitPathWindow(ctx, tr, destHash); err != nil {
fmt.Fprintln(stderr, errMsg(stderr, err.Error()))
return 1
}
if remote, err := identity.Recall(destHash); err == nil {
if c, name, ok := rnsutil.DetectShellMode(destHash, remote); ok {
compat = c
appName = name
}
}
}
l, err := rnsutil.EstablishRgoshLink(ctx, tr, destHash, appName)
if err != nil {
fmt.Fprintln(stderr, errMsg(stderr, err.Error()))
return 1
}
defer l.Teardown()
ch := l.GetChannel()
if compat {
_ = rgosh.RegisterCompat(ch)
} else {
_ = rgosh.RegisterNative(ch)
}
exitCh := make(chan int, 1)
done := make(chan struct{})
var doneOnce sync.Once
closeDone := func() { doneOnce.Do(func() { close(done) }) }
var endMu sync.Mutex
var endMsg string
setEndMsg := func(msg string) {
endMu.Lock()
if endMsg == "" && msg != "" {
endMsg = msg
}
endMu.Unlock()
}
sess := rgosh.NewSession(rgosh.Config{
Compat: compat,
Listener: false,
}, rgosh.ChannelSender{Ch: ch})
var exited atomic.Bool
var userInt atomic.Bool
var stdinOff atomic.Bool
sess.OnExit = func(code int) {
exited.Store(true)
select {
case exitCh <- code:
default:
}
closeDone()
}
sess.OnTeardown = closeDone
sess.OnAuthDenied = func(reason string) {
setEndMsg("auth denied: " + reason)
closeDone()
}
sess.OnError = func(msg string, fatal bool) {
setEndMsg(msg)
if fatal {
closeDone()
}
}
sess.OnStdout = func(data []byte) { _, _ = stdout.Write(data) }
sess.OnStderr = func(data []byte) { _, _ = stderr.Write(data) }
ch.AddMessageHandler(func(msg rgosh.Message) bool {
_ = sess.HandleMessage(msg)
return true
})
// Register handlers before Identify so peer Error/Version are not dropped.
l.SetLinkClosedCallback(func(*link.Link) {
if !exited.Load() && !userInt.Load() {
setEndMsg("link closed")
}
sess.Close()
closeDone()
})
if !opts.noid {
if err := l.Identify(id); err != nil {
fmt.Fprintln(stderr, errMsg(stderr, "identify: "+err.Error()))
return 1
}
}
if !compat && !opts.noid {
authDeadline := time.After(2 * time.Second)
for !sess.Authed() {
select {
case <-authDeadline:
goto afterAuth
case <-done:
return 1
case <-time.After(20 * time.Millisecond):
}
}
}
afterAuth:
if err := sess.SendVersion(); err != nil {
fmt.Fprintln(stderr, errMsg(stderr, err.Error()))
return 1
}
deadline := time.After(opts.timeout)
lastVers := time.Now()
for sess.State() == rgosh.StateWaitVers {
select {
case <-deadline:
fmt.Fprintln(stderr, errMsg(stderr, "timeout waiting for version"))
return 1
case <-done:
return 1
case <-time.After(50 * time.Millisecond):
// Rare resend only: peer moves to WAIT_CMD after the first Version,
// so a fast retry is a protocol error (LSSTATE_WAIT_CMD).
if sess.State() == rgosh.StateWaitVers && time.Since(lastVers) >= 2*time.Second {
_ = sess.SendVersion()
lastVers = time.Now()
}
}
}
if st := sess.State(); st != rgosh.StateWaitCmd && st != rgosh.StateRunning {
fmt.Fprintln(stderr, errMsg(stderr, "session ended before exec ("+st.String()+")"))
return 1
}
pipeStdin := !isTTY(os.Stdin)
pipeStdout := !isTTY(os.Stdout)
pipeStderr := !isTTY(os.Stderr)
interactive := !pipeStdin && !pipeStdout
termName := os.Getenv("TERM")
rows, cols := ttySize()
cmdline := opts.cmdline
if len(cmdline) == 0 {
cmdline = nil
}
var restoreTTY func()
if interactive {
fd := fileFD(os.Stdin)
st, err := term.MakeRaw(fd)
if err != nil {
fmt.Fprintln(stderr, errMsg(stderr, "tty raw: "+err.Error()))
return 1
}
restoreTTY = func() { _ = term.Restore(fd, st) }
defer restoreTTY()
}
if err := sess.SendExec(rgosh.ExecRequest{
Cmdline: cmdline,
PipeStdin: pipeStdin,
PipeStdout: pipeStdout,
PipeStderr: pipeStderr,
Term: termName,
Rows: rows,
Cols: cols,
}); err != nil {
fmt.Fprintln(stderr, errMsg(stderr, err.Error()))
return 1
}
line := opts.lineMode
raw := opts.rawMode
if interactive && !line {
// Interactive TTY must send keystrokes immediately (arrow keys, menus).
raw = true
}
if !raw && !line {
if rtt := time.Duration(l.GetRTT() * float64(time.Second)); rgosh.PreferLineForRTT(rtt) {
line = true
}
}
var coalesceWindow time.Duration
if raw {
coalesceWindow = 0
line = false
} else if !line {
coalesceWindow = rgosh.DefaultCoalesceWindow
}
coal := rgosh.NewCoalescer(line, coalesceWindow, func(b []byte) {
_ = sess.SendStream(rgosh.StreamStdin, b, false)
})
esc := rgosh.NewEscapeFilter()
lineOn := line
var lastWinch time.Time
var lastWinchMu sync.Mutex
sendWinch := func() {
gap := max(time.Duration(l.GetRTT()*25*float64(time.Second)), 50*time.Millisecond)
lastWinchMu.Lock()
if !lastWinch.IsZero() && time.Since(lastWinch) < gap {
lastWinchMu.Unlock()
return
}
lastWinch = time.Now()
lastWinchMu.Unlock()
r, c := ttySize()
_ = sess.SendWinSize(r, c, 0, 0)
}
var shutOnce sync.Once
shutdown := func() {
shutOnce.Do(func() {
userInt.Store(true)
stdinOff.Store(true)
if restoreTTY != nil {
restoreTTY()
restoreTTY = nil
}
coal.Close()
_ = sess.SendStream(rgosh.StreamStdin, nil, true)
rgosh.ChannelSender{Ch: ch}.WaitTxIdle(2 * time.Second)
sess.Close()
closeDone()
})
}
go func() {
buf := make([]byte, 4096)
for {
if stdinOff.Load() {
return
}
n, err := os.Stdin.Read(buf)
if n > 0 {
chunk := buf[:n]
if interactive {
var action rgosh.EscapeAction
chunk, action = esc.Feed(chunk)
switch action {
case rgosh.EscapeQuit:
shutdown()
return
case rgosh.EscapeHelp:
_, _ = stdout.Write([]byte(rgosh.EscapeHelpText))
case rgosh.EscapeToggleLine:
lineOn = !lineOn
coal.SetLineMode(lineOn)
if lineOn {
_, _ = stdout.Write([]byte("\r\nLine-interactive mode enabled\r\n"))
} else {
_, _ = stdout.Write([]byte("\r\nLine-interactive mode disabled\r\n"))
}
}
}
if len(chunk) > 0 {
_, _ = coal.Write(chunk)
}
}
if err != nil {
coal.Close()
_ = sess.SendStream(rgosh.StreamStdin, nil, true)
return
}
}
}()
sig := make(chan os.Signal, 1)
notifyShellSignals(sig)
startResizePoll(done, sendWinch)
go func() {
for {
select {
case <-done:
return
case s := <-sig:
if shellSignalWinch(s, sendWinch) {
continue
}
shutdown()
return
}
}
}()
finish := func(code int, timedOut bool) int {
if restoreTTY != nil {
restoreTTY()
restoreTTY = nil
}
if userInt.Load() {
if opts.mirror {
return code
}
return 130
}
endMu.Lock()
msg := endMsg
endMu.Unlock()
if timedOut {
fmt.Fprintln(stderr, errMsg(stderr, "timeout waiting for remote exit"))
return 1
}
if msg != "" {
fmt.Fprintln(stderr, errMsg(stderr, msg))
}
if opts.mirror {
return code
}
if msg != "" {
return 1
}
return 0
}
if interactive {
select {
case code := <-exitCh:
return finish(code, false)
case <-done:
code := 0
select {
case code = <-exitCh:
case <-time.After(3 * time.Second):
}
return finish(code, false)
}
}
select {
case code := <-exitCh:
return finish(code, false)
case <-done:
code := 0
select {
case code = <-exitCh:
default:
}
return finish(code, false)
case <-time.After(opts.timeout + 30*time.Second):
return finish(1, true)
}
}
func fileFD(f *os.File) int {
// #nosec G115 -- kernel fds are small integers well below MaxInt on all supported OSes
return int(f.Fd())
}
func isTTY(f *os.File) bool {
return term.IsTerminal(fileFD(f))
}
func ttySize() (rows, cols int) {
rows, cols = 24, 80
w, h, err := term.GetSize(fileFD(os.Stdout))
if err != nil || w <= 0 || h <= 0 {
w, h, err = term.GetSize(fileFD(os.Stdin))
}
if err == nil && w > 0 && h > 0 {
cols, rows = w, h
}
return rows, cols
}