// SPDX-License-Identifier: Apache-2.0 // Copyright (c) 2024-2026 Quad4.io package cli import ( "encoding/hex" "flag" "fmt" "io" "os" "os/signal" "path/filepath" "strings" "sync" "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/resource" "quad4/reticulum-go/pkg/rnsutil" "quad4/reticulum-go/pkg/term" "quad4/reticulum-go/pkg/transport" ) func RunCP(args []string, opt ...Options) int { stdout, stderr := cliIO(opt) fs := flag.NewFlagSet("rgocp", flag.ContinueOnError) fs.SetOutput(stderr) configDir := fs.String("config", "", "path to config directory") identityPath := fs.String("identity", "", "path to identity file (default: storage/identities/rncp)") listenMode := fs.Bool("l", false, "listen for incoming transfers") fetchMode := fs.Bool("f", false, "fetch file from remote (requires -f path and destination)") fetchPath := fs.String("F", "", "remote file path to fetch (with -f)") timeoutSec := fs.Float64("w", 0, "path and link timeout in seconds (0 = adaptive from interface bitrate)") silent := fs.Bool("s", false, "silent (minimal progress)") noCompress := fs.Bool("no-compress", false, "disable auto compression") allowAll := fs.Bool("a", false, "allow unauthenticated senders (listen)") allowFetch := fs.Bool("allow-fetch", false, "allow fetch_file requests (listen)") jail := fs.String("jail", "", "restrict fetch paths under this directory") saveDir := fs.String("save", "", "directory to save received files") overwrite := fs.Bool("overwrite", false, "overwrite existing files on receive") announceSec := fs.Float64("announce", 0, "announce interval seconds (0 = once at start, <0 = never)") printID := fs.Bool("p", false, "print identity and destination hash then exit") var allowed flagStringList fs.Var(&allowed, "allowed", "allowed identity hash (repeatable)") bindFlagUsage(fs, "rgocp - file transfer over RNS links", "Send, receive, fetch, or listen for file transfers.", []helpLine{ {Cmd: "rgocp [flags] "}, {Cmd: "rgocp -l [flags]"}, {Cmd: "rgocp -f -F [flags] "}, {Cmd: "reticulum-go cp [flags] ..."}, }, "rgocp document.pdf ", "rgocp -l -save ./incoming", "rgocp -f -F /path/on/remote ", ) if err := fs.Parse(args); err != nil { return 2 } cfg, err := rnsutil.LoadConfigDir(*configDir) if err != nil { diagErr(stderr, "config", err) return 1 } idPath := *identityPath if idPath == "" { idPath = rnsutil.RNCPIdentityPath(rnsutil.StorageDir(cfg)) } id, err := rnsutil.PrepareRNCPIdentity(idPath) if err != nil { diagErr(stderr, "identity", err) return 2 } timeout := max(time.Duration(*timeoutSec*float64(time.Second)), 0) if *printID { destHash := destination.Hash(id, rnsutil.RNCPAppName, rnsutil.RNCPAspect) fmt.Fprintf(stdout, "%s : %s\n", infoMsg(stdout, "Identity"), hex.EncodeToString(id.Hash())) fmt.Fprintf(stdout, "%s : %s\n", infoMsg(stdout, "Listening on"), hex.EncodeToString(destHash)) return 0 } n, err := node.New(cfg) if err != nil { diagErr(stderr, "node", err) return 1 } if err := n.Start(); err != nil { diagErr(stderr, "start", err) return 1 } defer n.Stop() tr := n.Transport() switch { case *listenMode: return runListen(tr, id, listenOpts{ allowAll: *allowAll, allowFetch: *allowFetch, jail: *jail, saveDir: *saveDir, overwrite: *overwrite, noCompress: *noCompress, announceSec: *announceSec, allowedCLI: allowed, stderr: stderr, }) case *fetchMode: if fs.NArg() != 1 || *fetchPath == "" { usageErr(stderr, "rgocp -f -F [flags] ") return 2 } destHash, err := rnsutil.ParseDestHash(fs.Arg(0)) if err != nil { fmt.Fprintf(stderr, "%v\n", err) return 1 } return runFetch(tr, id, destHash, *fetchPath, timeout, *silent, *saveDir, *overwrite, stdout, stderr) default: if fs.NArg() != 2 { usageErr(stderr, "rgocp [flags] ") usageErr(stderr, "rgocp -l [flags]") usageErr(stderr, "rgocp -f -F [flags] ") return 2 } filePath := fs.Arg(0) destHash, err := rnsutil.ParseDestHash(fs.Arg(1)) if err != nil { fmt.Fprintf(stderr, "%v\n", err) return 1 } return runSend(tr, id, filePath, destHash, timeout, *silent, !*noCompress, stdout, stderr) } } type listenOpts struct { allowAll bool allowFetch bool jail string saveDir string overwrite bool noCompress bool announceSec float64 allowedCLI []string stderr io.Writer } func runListen(tr *transport.Transport, id *identity.Identity, opts listenOpts) int { stderr := opts.stderr if stderr == nil { stderr = os.Stderr } allowed, err := rnsutil.LoadAllowedIdentities(opts.allowedCLI) if err != nil { fmt.Fprintf(stderr, "%v\n", err) return 1 } if !opts.allowAll && len(allowed) == 0 { fmt.Fprintln(stderr, warnMsg(stderr, "No allowed identities configured, rgocp will not accept any files!")) } saveDir := opts.saveDir if saveDir != "" { abs, err := filepath.Abs(saveDir) if err != nil || !dirWritable(abs) { fmt.Fprintln(stderr, errMsg(stderr, "Output directory not found or not writable")) return 3 } saveDir = abs } var jailAbs string if opts.jail != "" { jailAbs, err = filepath.Abs(opts.jail) if err != nil { diagErr(stderr, "jail", err) return 1 } fmt.Fprintln(stderr, infoMsg(stderr, fmt.Sprintf("Restricting fetch requests to paths under %q", jailAbs))) } dest, err := destination.New(id, destination.In, destination.Single, rnsutil.RNCPAppName, tr, rnsutil.RNCPAspect) if err != nil { diagErr(stderr, "destination", err) return 1 } dest.AcceptsLinks(true) var mu sync.Mutex dest.SetLinkEstablishedCallback(func(lnk any) { l, ok := lnk.(*link.Link) if !ok || l == nil { return } fmt.Fprintln(stderr, infoMsg(stderr, "Incoming link established")) _ = l.SetResourceStrategy(link.AcceptApp) 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, "Sender not allowed, tearing down link")) lnk.Teardown() } }) l.SetResourceCallback(func(any) bool { remote := l.GetRemoteIdentity() if remote != nil && rnsutil.AllowedContains(allowed, remote.Hash()) { return true } return opts.allowAll }) l.SetResourceStartedCallback(func(any) { fmt.Fprintln(stderr, infoMsg(stderr, "Starting resource transfer")) }) l.SetResourceConcludedCallback(func(p any) { var data []byte var meta map[string]any switch v := p.(type) { case link.IncomingResource: data = v.Data meta = v.Metadata case []byte: data = v default: fmt.Fprintln(stderr, errMsg(stderr, "Invalid data received, ignoring resource")) return } if meta == nil { fmt.Fprintln(stderr, errMsg(stderr, "Invalid data received, ignoring resource")) return } name := rnsutil.FilenameFromMetadata(meta) mu.Lock() path, err := rnsutil.WriteReceivedFile(saveDir, name, data, opts.overwrite) mu.Unlock() if err != nil { diagErr(stderr, "save", err) return } fmt.Fprintln(stderr, okMsg(stderr, fmt.Sprintf("Saved received file to %s", path))) }) }) if opts.allowFetch { allowMode := byte(destination.AllowList) allowList := allowed if opts.allowAll { allowMode = destination.AllowAll allowList = nil fmt.Fprintln(stderr, warnMsg(stderr, "Allowing unauthenticated fetch requests")) } _ = dest.RegisterRequestHandlerAny(rnsutil.RNCPFetchPath, func(_ string, data []byte, _ []byte, linkID []byte, _ *identity.Identity, _ int64) any { filePath, ok := resolveFetchPath(string(data), jailAbs) if !ok { return rnsutil.RNCPFetchNotAllowed } st, err := os.Stat(filePath) if err != nil || st.IsDir() { return false } li := tr.FindLink(linkID) target, ok := li.(*link.Link) if !ok || target == nil { return nil } body, err := os.ReadFile(filePath) // #nosec G304 -- jail-validated path if err != nil { return nil } res, err := resource.New(body, !opts.noCompress) if err != nil { return nil } _ = res.SetMetadata(map[string]any{"name": []byte(filepath.Base(filePath))}) go func() { _ = target.SendResource(res) }() return true }, allowMode, allowList) } fmt.Fprintln(stderr, okMsg(stderr, fmt.Sprintf("rgocp listening on %s", rnsutil.PrettyHex(dest.GetHash())))) if opts.announceSec >= 0 { _ = dest.Announce(false, nil, nil) if opts.announceSec > 0 { go func() { t := time.NewTicker(time.Duration(opts.announceSec * float64(time.Second))) defer t.Stop() for range t.C { _ = dest.Announce(false, nil, nil) } }() } } sig := make(chan os.Signal, 1) signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) <-sig return 0 } func resolveFetchPath(req, jail string) (string, bool) { req = strings.TrimSpace(req) if jail == "" { abs, err := filepath.Abs(req) if err != nil { return "", false } return abs, true } cleaned := strings.TrimPrefix(req, jail+"/") full := filepath.Join(jail, cleaned) abs, err := filepath.Abs(full) if err != nil { return "", false } if !strings.HasPrefix(abs, jail+string(os.PathSeparator)) && abs != jail { return "", false } // A Clean+HasPrefix check on the unresolved string is not enough: a // symlink inside jail can point outside it while the string still looks // contained. Resolve symlinks on both sides before trusting abs. resolvedJail, err := filepath.EvalSymlinks(jail) if err != nil { resolvedJail = jail } resolved, ok := evalExistingAncestorPath(abs) if !ok { return "", false } if resolved != resolvedJail && !strings.HasPrefix(resolved, resolvedJail+string(os.PathSeparator)) { return "", false } return resolved, true } // evalExistingAncestorPath resolves symlinks in path, walking up to the // nearest existing ancestor when the leaf (or more) does not exist yet, then // reattaches the missing suffix unresolved. A symlinked ancestor still // cannot smuggle a request outside the jail just because the final // component happens not to exist yet. func evalExistingAncestorPath(path string) (string, bool) { suffix := "" cur := path for { resolved, err := filepath.EvalSymlinks(cur) if err == nil { if suffix == "" { return resolved, true } return filepath.Join(resolved, suffix), true } if !os.IsNotExist(err) { return "", false } parent := filepath.Dir(cur) if parent == cur { return "", false } base := filepath.Base(cur) if suffix == "" { suffix = base } else { suffix = filepath.Join(base, suffix) } cur = parent } } func runSend(tr *transport.Transport, id *identity.Identity, filePath string, destHash []byte, timeout time.Duration, silent, compress bool, stdout, stderr io.Writer) int { if stdout == nil { stdout = os.Stdout } if stderr == nil { stderr = os.Stderr } if st, err := os.Stat(filePath); err != nil || st.IsDir() { fmt.Fprintln(stdout, errMsg(stdout, "File not found")) return 1 } ctx, cancel := rnsutil.CLIWaitContext(timeout) defer cancel() fmt.Fprintln(stdout, infoMsg(stdout, fmt.Sprintf("Path to %s requested", rnsutil.PrettyHex(destHash)))) l, err := rnsutil.EstablishRNCPLink(ctx, tr, destHash) if err != nil { fmt.Fprintf(stderr, "%v\n", err) return 1 } defer l.Teardown() if err := l.Identify(id); err != nil { diagErr(stderr, "identify", err) return 1 } progOut := term.FileOf(stderr) if progOut == nil { progOut = os.Stderr } prog := rnsutil.NewProgressPrinterTo(silent, progOut) start := time.Now() var lastGot int64 var lastAt time.Time err = rnsutil.SendFileOverLink(l, filePath, compress, func(pct float64, got, total int64) { now := time.Now() bps := 0.0 if !lastAt.IsZero() && now.After(lastAt) { bps = float64(got-lastGot) * 8 / now.Sub(lastAt).Seconds() } lastGot, lastAt = got, now prog.Update("Transferring", pct, got, total, bps) }) if err != nil { prog.Done(errMsg(stderr, "Transfer failed: "+err.Error())) return 1 } elapsed := time.Since(start) st, _ := os.Stat(filePath) size := int64(0) if st != nil { size = st.Size() } bps := float64(size) * 8 / elapsed.Seconds() prog.Done(okMsg(stderr, fmt.Sprintf("Transfer complete - %s in %s - %s/s", rnsutil.SizeString(float64(size), "B"), elapsed.Truncate(time.Millisecond), rnsutil.SizeString(bps, "b")))) fmt.Fprintln(stdout, okMsg(stdout, fmt.Sprintf("%s sent to %s", filepath.Base(filePath), rnsutil.PrettyHex(destHash)))) return 0 } func runFetch(tr *transport.Transport, id *identity.Identity, destHash []byte, remotePath string, timeout time.Duration, silent bool, saveDir string, overwrite bool, stdout, stderr io.Writer) int { _ = silent if stdout == nil { stdout = os.Stdout } if stderr == nil { stderr = os.Stderr } if saveDir != "" { abs, err := filepath.Abs(saveDir) if err != nil || !dirWritable(abs) { fmt.Fprintln(stderr, errMsg(stderr, "Output directory not found or not writable")) return 3 } saveDir = abs } ctx, cancel := rnsutil.CLIWaitContext(timeout) defer cancel() fmt.Fprintln(stdout, infoMsg(stdout, fmt.Sprintf("Path to %s requested", rnsutil.PrettyHex(destHash)))) l, err := rnsutil.EstablishRNCPLink(ctx, tr, destHash) if err != nil { fmt.Fprintf(stderr, "%v\n", err) return 1 } defer l.Teardown() if err := l.Identify(id); err != nil { diagErr(stderr, "identify", err) return 1 } done := make(chan struct{}) var received link.IncomingResource var once sync.Once _ = l.SetResourceStrategy(link.AcceptAll) l.SetResourceConcludedCallback(func(p any) { once.Do(func() { switch v := p.(type) { case link.IncomingResource: received = v case []byte: received = link.IncomingResource{Data: v} } close(done) }) }) receipt, err := l.Request(rnsutil.RNCPFetchPath, remotePath, timeout) if err != nil { diagErr(stderr, "request", err) return 1 } if err := rnsutil.WaitRequest(ctx, receipt); err != nil { fmt.Fprintf(stderr, "request timeout: %v\n", err) return 1 } switch rnsutil.ClassifyFetchResponse(receipt.GetResponseValue()) { case rnsutil.FetchNotAllowed: fmt.Fprintln(stdout, errMsg(stdout, fmt.Sprintf("Fetch request failed, fetching the file %s was not allowed by the remote", remotePath))) return 0 case rnsutil.FetchNotFound: fmt.Fprintln(stdout, errMsg(stdout, fmt.Sprintf("Fetch request failed, the file %s was not found on the remote", remotePath))) return 0 case rnsutil.FetchRemoteError: fmt.Fprintln(stdout, errMsg(stdout, "Fetch request failed due to an error on the remote system")) return 0 case rnsutil.FetchUnknown: fmt.Fprintln(stdout, errMsg(stdout, "Fetch request failed due to an unknown error (probably not authorised)")) return 0 case rnsutil.FetchFound: } select { case <-done: case <-ctx.Done(): fmt.Fprintln(stdout, errMsg(stdout, "The transfer failed")) return 1 } if len(received.Data) == 0 && received.Metadata == nil { fmt.Fprintln(stdout, errMsg(stdout, "The transfer failed")) return 1 } name := rnsutil.FilenameFromMetadata(received.Metadata) path, err := rnsutil.WriteReceivedFile(saveDir, name, received.Data, overwrite) if err != nil { diagErr(stderr, "save", err) return 1 } fmt.Fprintln(stdout, okMsg(stdout, fmt.Sprintf("%s fetched from %s -> %s", remotePath, rnsutil.PrettyHex(destHash), path))) return 0 } func dirWritable(path string) bool { st, err := os.Stat(path) if err != nil || !st.IsDir() { return false } f, err := os.CreateTemp(path, ".rgocp-write-*") if err != nil { return false } name := f.Name() _ = f.Close() _ = os.Remove(name) return true } type flagStringList []string func (f *flagStringList) String() string { return strings.Join(*f, ",") } func (f *flagStringList) Set(v string) error { *f = append(*f, v) return nil }