// xs client // // Copyright (c) 2017-2020 Russell Magee // Licensed under the terms of the MIT license (see LICENSE.mit in this // distribution) // // golang implementation by Russ Magee (rmagee_at_gmail.com) package main import ( "bytes" "encoding/binary" "errors" "flag" "fmt" "io" "log" "math/rand" "os" "os/exec" "os/user" "path" "path/filepath" "runtime" "runtime/pprof" "strings" "sync" "syscall" "time" "net/http" _ "net/http/pprof" //nolint:gosec xs "blitter.com/go/xs" "blitter.com/go/xs/logger" "blitter.com/go/xs/spinsult" "blitter.com/go/xs/xsnet" isatty "github.com/mattn/go-isatty" ) var ( version string gitCommit string // set in -ldflags by build // wg controls when the goroutines handling client I/O complete wg sync.WaitGroup kcpMode string // set to a valid KCP BlockCrypt alg tag to use rather than TCP // Log defaults to regular syslog output (no -d) Log *logger.Writer cpuprofile string memprofile string ) //////////////////////////////////////////////////// const ( CmdExitedEarly = 2 XSNetDialFailed = 3 ErrReadingAuthReply = 253 ServerRejectedSecureProposal = 254 GeneralProtocolErr = 255 ) const ( DeadCharPrefix = 0x1d ) // Praise Bob. Do not remove, lest ye lose Slack. const bob = string("\r\n\r\n" + "@@@@@@@^^~~~~~~~~~~~~~~~~~~~~^@@@@@@@@@\r\n" + "@@@@@@^ ~^ @ @@ @ @ @ I ~^@@@@@@\r\n" + "@@@@@ ~ ~~ ~I @@@@@\r\n" + "@@@@' ' _,w@< @@@@ .\r\n" + "@@@@ @@@@@@@@w___,w@@@@@@@@ @ @@@\r\n" + "@@@@ @@@@@@@@@@@@@@@@@@@@@@ I @@@ Bob\r\n" + "@@@@ @@@@@@@@@@@@@@@@@@@@*@[ i @@@\r\n" + "@@@@ @@@@@@@@@@@@@@@@@@@@[][ | ]@@@ bOb\r\n" + "@@@@ ~_,,_ ~@@@@@@@~ ____~ @ @@@\r\n" + "@@@@ _~ , , `@@@~ _ _`@ ]L J@@@ o\r\n" + "@@@@ , @@w@ww+ @@@ww``,,@w@ ][ @@@@\r\n" + "@@@@, @@@@www@@@ @@@@@@@ww@@@@@[ @@@@ BOB\r\n" + "@@@@@_|| @@@@@@P' @@P@@@@@@@@@@@[|c@@@@\r\n" + "@@@@@@w| '@@P~ P]@@@-~, ~Y@@^'],@@@@@@ . o\r\n" + "@@@@@@@[ _ _J@@Tk ]]@@@@@@\r\n" + "@@@@@@@@,@ @@, c,,,,,,,y ,w@@[ ,@@@@@@@\r\n" + "@@@@@@@@@ i @w ====--_@@@@@ @@@@@@@@ o .\r\n" + "@@@@@@@@@@`,P~ _ ~^^^^Y@@@@@ @@@@@@@@@\r\n" + "@@@@^^=^@@^ ^' ,ww,w@@@@@ _@@@@@@@@@@ B o B\r\n" + "@@@_xJ~ ~ , @@@@@@@P~_@@@@@@@@@@@@\r\n" + "@@ @, ,@@@,_____ _,J@@@@@@@@@@@@@\r\n" + "@@L `' ,@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\r\n" + "@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\r\n" + "\r\n") type ( // Handler for special functions invoked by escSeqs escHandler func(io.Writer) // escSeqs is a map of special keystroke sequences to trigger escHandlers escSeqs map[byte]escHandler ) // Copy copies from src to dst until either EOF is reached // on src or an error occurs. It returns the number of bytes // copied and the first error encountered while copying, if any. // // A successful Copy returns err == nil, not err == EOF. // Because Copy is defined to read from src until EOF, it does // not treat an EOF from Read as an error to be reported. // // If src implements the WriterTo interface, // the copy is implemented by calling src.WriteTo(dst). // Otherwise, if dst implements the ReaderFrom interface, // the copy is implemented by calling dst.ReadFrom(src). // // This is identical to stdlib pkg/io.Copy save that it // calls a client-custom version of copyBuffer(), which allows // some client escape sequences to trigger special actions during // interactive sessions. // // (See go doc xs/xs.{escSeqs,escHandler}) func Copy(dst io.Writer, src io.Reader) (written int64, err error) { written, err = copyBuffer(dst, src, nil) return } // copyBuffer is the actual implementation of Copy and CopyBuffer. // if buf is nil, one is allocated. // // This private version of copyBuffer is derived from the // go stdlib pkg/io, with escape sequence interpretation to trigger // some special client-side actions. // // (See go doc xs/xs.{escSeqs,escHandler}) func copyBuffer(dst io.Writer, src io.Reader, buf []byte) (written int64, err error) { // NOTE: using dst.Write() in these esc funcs will cause the output // to function as a 'macro', outputting as if user typed the sequence // (that is, the client 'sees' the user type it, and the server 'sees' // it as well). // // Using os.Stdout outputs to the client's term w/o it or the server // 'seeing' the output. // // TODO: Devise a way to signal to main client thread that // a goroutine should be spawned to do long-lived tasks for // some esc sequences (eg., a time ticker in the corner of terminal, // or tunnel traffic indicator - note we cannot just spawn a goroutine // here, as copyBuffer() returns after each burst of data. Scope must // outlive individual copyBuffer calls). escs := escSeqs{ 'i': func(io.Writer) { os.Stdout.Write([]byte("\x1b[s\x1b[2;1H\x1b[1;31m[HKEXSH]\x1b[39;49m\x1b[u")) }, 't': func(io.Writer) { os.Stdout.Write([]byte("\x1b[1;32m[HKEXSH]\x1b[39;49m")) }, 'B': func(io.Writer) { os.Stdout.Write([]byte("\x1b[1;32m" + bob + "\x1b[39;49m")) }, } /* // If the reader has a WriteTo method, use it to do the copy. // Avoids an allocation and a copy. if wt, ok := src.(io.WriterTo); ok { return wt.WriteTo(dst) } // Similarly, if the writer has a ReadFrom method, use it to do the copy. if rt, ok := dst.(io.ReaderFrom); ok { return rt.ReadFrom(src) } */ //nolint:gocritic,nolintlint if buf == nil { size := 32 * 1024 if l, ok := src.(*io.LimitedReader); ok && int64(size) > l.N { if l.N < 1 { size = 1 } else { size = int(l.N) } } buf = make([]byte, size) } var seqPos int for { nr, er := src.Read(buf) if nr > 0 { // Look for sequences to trigger client-side diags // A repeat of 4 keys (conveniently 'dead' chars for most // interactive shells; here CTRL-]) shall introduce // some special responses or actions on the client side. if seqPos < 4 { //nolint:gomnd if buf[0] == DeadCharPrefix { seqPos++ } } else { if v, ok := escs[buf[0]]; ok { v(dst) nr-- buf = buf[1:] } seqPos = 0 } nw, ew := dst.Write(buf[0:nr]) if nw > 0 { written += int64(nw) } if ew != nil { err = ew break } if nr != nw { err = io.ErrShortWrite break } } if er != nil { if er != io.EOF { err = er } break } } return written, err } //////////////////////////////////////////////////// // GetSize gets the terminal size using 'stty' command // // TODO: do in code someday instead of using external 'stty' func GetSize() (cols, rows int, err error) { cmd := exec.Command("stty", "size") // #nosec cmd.Stdin = os.Stdin out, err := cmd.Output() if err != nil { log.Println(err) cols, rows = 80, 24 // failsafe } else { n, err := fmt.Sscanf(string(out), "%d %d\n", &rows, &cols) if n < 2 || rows < 0 || cols < 0 || rows > 9000 || cols > 9000 || err != nil { log.Printf("GetSize error: rows:%d cols:%d; %v\n", rows, cols, err) } } return } func buildCmdRemoteToLocal(copyQuiet bool, copyLimitBPS uint, destPath string) (captureStderr bool, cmd string, args []string) { // Detect if we have 'pv' // pipeview http://www.ivarch.com/programs/pv.shtml // and use it for nice client progress display. _, pverr := os.Stat("/usr/bin/pv") if pverr != nil { _, pverr = os.Stat("/usr/local/bin/pv") } if copyQuiet || pverr != nil { // copyQuiet and copyLimitBPS are not applicable in dumb copy mode captureStderr = true cmd = xs.GetTool("tar") args = []string{"-xz", "-C", destPath} } else { // TODO: Query remote side for total file/dir size bandwidthInBytesPerSec := " -L " + fmt.Sprintf("%d ", copyLimitBPS) displayOpts := " -pre " //nolint:goconst cmd = xs.GetTool("bash") args = []string{"-c", "pv " + displayOpts + bandwidthInBytesPerSec + "| tar -xz -C " + destPath} } log.Printf("[%v %v]\n", cmd, args) return } func buildCmdLocalToRemote(copyQuiet bool, copyLimitBPS uint, files string) (captureStderr bool, cmd string, args []string) { // Detect if we have 'pv' // pipeview http://www.ivarch.com/programs/pv.shtml // and use it for nice client progress display. _, pverr := os.Stat("/usr/bin/pv") if pverr != nil { _, pverr = os.Stat("/usr/local/bin/pv") } if pverr != nil { // copyQuiet and copyLimitBPS are not applicable in dumb copy mode captureStderr = true cmd = xs.GetTool("tar") args = []string{"-cz", "-f", "/dev/stdout"} files = strings.TrimSpace(files) // Awesome fact: tar actually can take multiple -C args, and // changes to the dest dir *as it sees each one*. This enables // its use below, where clients can send scattered sets of source // files and dirs to be extracted to a single dest dir server-side, // whilst preserving the subtrees of dirs on the other side. // Eg., tar -c -f /dev/stdout -C /dirA fileInA -C /some/where/dirB fileInB /foo/dirC // packages fileInA, fileInB, and dirC at a single toplevel in the tar. // The tar authors are/were real smarties :) // // This is the 'scatter/gather' logic to allow specification of // files and dirs in different trees to be deposited in a single // remote destDir. for _, v := range strings.Split(files, " ") { v, _ = filepath.Abs(v) // #nosec dirTmp, fileTmp := path.Split(v) if dirTmp == "" { args = append(args, fileTmp) } else { args = append(args, "-C", dirTmp, fileTmp) } } } else { captureStderr = copyQuiet bandwidthInBytesPerSec := " -L " + fmt.Sprintf("%d", copyLimitBPS) displayOpts := " -pre " //nolint:goconst,nolintlint cmd = xs.GetTool("bash") args = []string{"-c", xs.GetTool("tar") + " -cz -f /dev/stdout "} files = strings.TrimSpace(files) // Awesome fact: tar actually can take multiple -C args, and // changes to the dest dir *as it sees each one*. This enables // its use below, where clients can send scattered sets of source // files and dirs to be extracted to a single dest dir server-side, // whilst preserving the subtrees of dirs on the other side. // Eg., tar -c -f /dev/stdout -C /dirA fileInA -C /some/where/dirB fileInB /foo/dirC // packages fileInA, fileInB, and dirC at a single toplevel in the tar. // The tar authors are/were real smarties :) // // This is the 'scatter/gather' logic to allow specification of // files and dirs in different trees to be deposited in a single // remote destDir. for _, v := range strings.Split(files, " ") { v, _ = filepath.Abs(v) // #nosec dirTmp, fileTmp := path.Split(v) if dirTmp == "" { args[1] = args[1] + fileTmp + " " } else { args[1] = args[1] + " -C " + dirTmp + " " + fileTmp + " " } } args[1] = args[1] + "| pv" + displayOpts + bandwidthInBytesPerSec + " -s " + getTreeSizeSubCmd(files) + " -c" } log.Printf("[%v %v]\n", cmd, args) return } func getTreeSizeSubCmd(paths string) (c string) { if runtime.GOOS == "linux" { c = " $(du -cb " + paths + " | tail -1 | cut -f 1) " } else { c = " $(expr $(du -c " + paths + ` | tail -1 | cut -f 1) \* 1024) ` } return c } // doCopyMode begins a secure xs local<->remote file copy operation. // // TODO: reduce gocyclo func doCopyMode(conn *xsnet.Conn, remoteDest bool, files string, copyQuiet bool, copyLimitBPS uint, rec *xs.Session) (exitStatus uint32, err error) { if remoteDest { log.Println("local files:", files, "remote filepath:", string(rec.Cmd())) var c *exec.Cmd // os.Clearenv() // os.Setenv("HOME", u.HomeDir) // os.Setenv("TERM", "vt102") // TODO: server or client option? captureStderr, cmdName, cmdArgs := buildCmdLocalToRemote(copyQuiet, copyLimitBPS, strings.TrimSpace(files)) c = exec.Command(cmdName, cmdArgs...) // #nosec c.Dir, _ = os.Getwd() // #nosec log.Println("[wd:", c.Dir, "]") c.Stdout = conn stdErrBuffer := new(bytes.Buffer) if captureStderr { c.Stderr = stdErrBuffer } else { c.Stderr = os.Stderr } // Start the command (no pty) err = c.Start() // returns immediately ///////////// // NOTE: There is, apparently, a bug in Go stdlib here. Start() // can actually return immediately, on a command which *does* // start but exits quickly, with c.Wait() error // "c.Wait status: exec: not started". // As in this example, attempting a client->server copy to // a nonexistent remote dir (it's tar exiting right away, exitStatus // 2, stderr // /bin/tar -xz -C /home/someuser/nosuchdir // stderr: fork/exec /bin/tar: no such file or directory // // In this case, c.Wait() won't give us the real // exit status (is it lost?). ///////////// if err != nil { fmt.Println("cmd exited immediately. Cannot get cmd.Wait().ExitStatus()") err = errors.New("cmd exited prematurely") exitStatus = uint32(CmdExitedEarly) } else { if err = c.Wait(); err != nil { if exiterr, ok := err.(*exec.ExitError); ok { // The program has exited with an exit code != 0 // This works on both Unix and Windows. Although package // syscall is generally platform dependent, WaitStatus is // defined for both Unix and Windows and in both cases has // an ExitStatus() method with the same signature. if status, ok := exiterr.Sys().(syscall.WaitStatus); ok { exitStatus = uint32(status.ExitStatus()) if captureStderr { fmt.Print(stdErrBuffer) } } } } // send CSOExitStatus to inform remote (server) end cp is done log.Println("Sending local exitStatus:", exitStatus) r := make([]byte, 4) //nolint:gomnd binary.BigEndian.PutUint32(r, exitStatus) _, we := conn.WritePacket(r, xsnet.CSOExitStatus) if we != nil { fmt.Println("Error:", we) } // Do a final read for remote's exit status s := make([]byte, 4) //nolint:gomnd _, remErr := conn.Read(s) if remErr != io.EOF && !strings.Contains(remErr.Error(), "use of closed network") && !strings.Contains(remErr.Error(), "connection reset by peer") { fmt.Printf("*** remote status Read() failed: %v\n", remErr) } else { conn.SetStatus(0) // cp finished OK } // If local side status was OK, use remote side's status if exitStatus == 0 { exitStatus = uint32(conn.GetStatus()) log.Println("Received remote exitStatus:", exitStatus) } log.Printf("*** client->server cp finished , status %d ***\n", conn.GetStatus()) } } else { log.Println("remote filepath:", string(rec.Cmd()), "local files:", files) destPath := files _, cmdName, cmdArgs := buildCmdRemoteToLocal(copyQuiet, copyLimitBPS, destPath) c := exec.Command(cmdName, cmdArgs...) // #nosec c.Stdin = conn c.Stdout = os.Stdout c.Stderr = os.Stderr // Start the command (no pty) err = c.Start() // returns immediately if err != nil { fmt.Println(err) } else { if err = c.Wait(); err != nil { if exiterr, ok := err.(*exec.ExitError); ok { // The program has exited with an exit code != 0 // This works on both Unix and Windows. Although package // syscall is generally platform dependent, WaitStatus is // defined for both Unix and Windows and in both cases has // an ExitStatus() method with the same signature. if status, ok := exiterr.Sys().(syscall.WaitStatus); ok { exitStatus = uint32(status.ExitStatus()) } } } // return local status, if nonzero; // otherwise, return remote status if nonzero if exitStatus == 0 { exitStatus = uint32(conn.GetStatus()) } log.Printf("*** server->client cp finished, status %d ***\n", conn.GetStatus()) } } return } // doShellMode begins an xs shell session (one-shot command or // interactive). func doShellMode(isInteractive bool, conn *xsnet.Conn, oldState *xs.State, rec *xs.Session) { // Client reader (from server) goroutine // Read remote end's stdout wg.Add(1) // #gv:s/label=\"doShellMode\$1\"/label=\"shellRemoteToStdin\"/ // TODO:.gv:doShellMode:1:shellRemoteToStdin shellRemoteToStdin := func() { defer func() { wg.Done() }() // By deferring a call to wg.Done(), // each goroutine guarantees that it marks // its direction's stream as finished. // pkg io/Copy expects EOF so normally this will // exit with inerr == nil _, inerr := io.Copy(os.Stdout, conn) if inerr != nil { restoreTermState(oldState) // Copy operations and user logging off will cause // a "use of closed network connection" so handle that // gracefully here if !strings.HasSuffix(inerr.Error(), "use of closed network connection") { log.Println(inerr) exitWithStatus(1) } } rec.SetStatus(uint32(conn.GetStatus())) log.Println("rec.status:", rec.Status()) if isInteractive { log.Println("[* Got EOF *]") restoreTermState(oldState) exitWithStatus(int(rec.Status())) } } go shellRemoteToStdin() // Only look for data from stdin to send to remote end // for interactive sessions. if isInteractive { handleTermResizes(conn) // client writer (to server) goroutine // Write local stdin to remote end wg.Add(1) // #gv:s/label=\"doShellMode\$2\"/label=\"shellStdinToRemote\"/ // TODO:.gv:doShellMode:2:shellStdinToRemote shellStdinToRemote := func() { defer wg.Done() _, outerr := func(conn *xsnet.Conn, r io.Reader) (w int64, e error) { // Copy() expects EOF so this will // exit with outerr == nil // NOTE we use a local implementation of Copy() to allow // for custom key sequences to trigger local actions w, e = Copy(conn, r) return w, e }(conn, os.Stdin) if outerr != nil { log.Println(outerr) fmt.Println(outerr) restoreTermState(oldState) log.Println("[Hanging up]") exitWithStatus(0) } } go shellStdinToRemote() } // Wait until both stdin and stdout goroutines finish before returning // (ensure client gets all data from server before closing) wg.Wait() } func usageShell() { fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0]) fmt.Fprintf(os.Stderr, "%s [opts] [user]@server\n", os.Args[0]) flag.PrintDefaults() } func usageCp() { fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0]) fmt.Fprintf(os.Stderr, "%s [opts] srcFileOrDir [...] [user]@server[:dstpath]\n", os.Args[0]) fmt.Fprintf(os.Stderr, "%s [opts] [user]@server[:srcFileOrDir] dstPath\n", os.Args[0]) flag.PrintDefaults() } // rejectUserMsg snarkily rebukes users giving incorrect // credentials. // // TODO: do this from the server side and have client just emit that func rejectUserMsg() string { return "Begone, " + spinsult.GetSentence() + "\r\n" } // Transmit request to server for it to set up the remote end of a tunnel // // Server responds with [CSOTunAck:rport] or [CSOTunRefused:rport] // (handled in xsnet.Read()) func reqTunnel(hc *xsnet.Conn, lp uint16 /*, p string*/ /*net.Addr*/, rp uint16) { // Write request to server so it can attempt to set up its end var bTmp bytes.Buffer if e := binary.Write(&bTmp, binary.BigEndian, lp); e != nil { fmt.Fprintln(os.Stderr, "reqTunnel:", e) } if e := binary.Write(&bTmp, binary.BigEndian, rp); e != nil { fmt.Fprintln(os.Stderr, "reqTunnel:", e) } _ = logger.LogDebug(fmt.Sprintln("[Client sending CSOTunSetup]")) if n, e := hc.WritePacket(bTmp.Bytes(), xsnet.CSOTunSetup); e != nil || n != len(bTmp.Bytes()) { fmt.Fprintln(os.Stderr, "reqTunnel:", e) } } func parseNonSwitchArgs(a []string) (user, host, path string, isDest bool, otherArgs []string) { // Whether fancyArg is src or dst file depends on flag.Args() index; // fancyArg as last flag.Args() element denotes dstFile // fancyArg as not-last flag.Args() element denotes srcFile var fancyUser, fancyHost, fancyPath string for i, arg := range a { if strings.Contains(arg, ":") || strings.Contains(arg, "@") { fancyArg := strings.Split(flag.Arg(i), "@") var fancyHostPath []string if len(fancyArg) < 2 { //nolint:gomnd //TODO: no user specified, use current fancyUser = "[default:getUser]" fancyHostPath = strings.Split(fancyArg[0], ":") } else { // user@.... fancyUser = fancyArg[0] fancyHostPath = strings.Split(fancyArg[1], ":") } // [...@]host[:path] if len(fancyHostPath) > 1 { fancyPath = fancyHostPath[1] } fancyHost = fancyHostPath[0] if i == len(a)-1 { isDest = true } } else { otherArgs = append(otherArgs, a[i]) } } return fancyUser, fancyHost, fancyPath, isDest, otherArgs } func launchTuns(conn *xsnet.Conn /*remoteHost string,*/, tuns string) { /*remAddrs, _ := net.LookupHost(remoteHost)*/ //nolint:gocritic,nolintlint if tuns == "" { return } tunSpecs := strings.Split(tuns, ",") for _, tunItem := range tunSpecs { var lPort, rPort uint16 _, _ = fmt.Sscanf(tunItem, "%d:%d", &lPort, &rPort) reqTunnel(conn, lPort /*remAddrs[0],*/, rPort) } } func sendSessionParams(conn io.Writer /* *xsnet.Conn*/, rec *xs.Session) (e error) { _, e = fmt.Fprintf(conn, "%d %d %d %d %d %d\n", len(rec.Op()), len(rec.Who()), len(rec.ConnHost()), len(rec.TermType()), len(rec.Cmd()), len(rec.AuthCookie(true))) if e != nil { return } _, e = conn.Write(rec.Op()) if e != nil { return } _, e = conn.Write(rec.Who()) if e != nil { return } _, e = conn.Write(rec.ConnHost()) if e != nil { return } _, e = conn.Write(rec.TermType()) if e != nil { return } _, e = conn.Write(rec.Cmd()) if e != nil { return } _, e = conn.Write(rec.AuthCookie(true)) return e } // TODO: reduce gocyclo func main() { //nolint: funlen, gocyclo var ( isInteractive bool vopt bool gopt bool // true: login via password, asking server to generate authToken dbg bool shellMode bool // true: act as shell, false: file copier cipherAlg string hmacAlg string kexAlg string server string port uint cmdStr string tunSpecStr string // lport1:rport1[,lport2:rport2,...] rekeySecs uint remodRequested bool // true: when rekeying, switch to random cipher/hmac alg copySrc []byte copyDst string copyQuiet bool copyLimitBPS uint authCookie string chaffEnabled bool chaffFreqMin uint chaffFreqMax uint chaffBytesMax uint op []byte ) // === Common (xs and xc) option parsing flag.BoolVar(&vopt, "v", false, "show version") flag.BoolVar(&dbg, "d", false, "debug logging") flag.StringVar(&cipherAlg, "c", "C_AES_256", "session `cipher`"+` C_AES_256 C_TWOFISH_128 C_BLOWFISH_64 C_CRYPTMT1 C_HOPSCOTCH C_CHACHA20_12`) flag.StringVar(&hmacAlg, "m", "H_SHA256", "session `HMAC`"+` H_SHA256 H_SHA512`) flag.StringVar(&kexAlg, "k", "KEX_HERRADURA512", "KEx `alg`"+` KEX_HERRADURA256 KEX_HERRADURA512 KEX_HERRADURA1024 KEX_HERRADURA2048 KEX_KYBER512 KEX_KYBER768 KEX_KYBER1024 KEX_NEWHOPE KEX_NEWHOPE_SIMPLE KEX_FRODOKEM_1344AES KEX_FRODOKEM_1344SHAKE KEX_FRODOKEM_976AES KEX_FRODOKEM_976SHAKE`) flag.StringVar(&kcpMode, "K", "unused", "KCP `alg`, one of [KCP_NONE | KCP_AES | KCP_BLOWFISH | KCP_CAST5 | KCP_SM4 | KCP_SALSA20 | KCP_SIMPLEXOR | KCP_TEA | KCP_3DES | KCP_TWOFISH | KCP_XTEA] to use KCP (github.com/xtaci/kcp-go) reliable UDP instead of TCP") //nolint:lll flag.UintVar(&port, "p", 2000, "``port") //nolint:gomnd,lll flag.UintVar(&rekeySecs, "r", 300, "rekey interval in `secs`") flag.BoolVar(&remodRequested, "R", false, "Borg Countermeasures (remodulate cipher/hmac alg on each rekey)") //nolint:gocritic,nolintlint // flag.StringVar(&authCookie, "a", "", "auth cookie") flag.BoolVar(&chaffEnabled, "e", true, "enable chaff pkts") flag.UintVar(&chaffFreqMin, "f", 100, "chaff pkt freq min `msecs`") //nolint:gomnd flag.UintVar(&chaffFreqMax, "F", 5000, "chaff pkt freq max `msecs`") //nolint:gomnd flag.UintVar(&chaffBytesMax, "B", 64, "chaff pkt size max `bytes`") //nolint:gomnd flag.StringVar(&cpuprofile, "cpuprofile", "", "write cpu profile to <`file`>") flag.StringVar(&memprofile, "memprofile", "", "write memory profile to <`file`>") // === xc vs. xs option parsing // Find out what program we are (shell or copier) myPath := strings.Split(os.Args[0], string(os.PathSeparator)) if myPath[len(myPath)-1] != "xc" && myPath[len(myPath)-1] != "_xc" && myPath[len(myPath)-1] != "xc.exe" && myPath[len(myPath)-1] != "_xc.exe" { // xs accepts a command (-x) but not // a srcpath (-r) or dstpath (-t) flag.StringVar(&cmdStr, "x", "", "run <`command`> (if not specified, run interactive shell)") flag.StringVar(&tunSpecStr, "T", "", "``tunnelspec - localPort:remotePort[,localPort:remotePort,...]") flag.BoolVar(&gopt, "g", false, "ask server to generate authtoken") shellMode = true flag.Usage = usageShell } else { flag.BoolVar(©Quiet, "q", false, "do not output progress bar during copy") flag.UintVar(©LimitBPS, "L", 8589934592, "copy max rate in bytes per sec") //nolint:gomnd flag.Usage = usageCp } flag.Parse() if vopt { fmt.Printf("version %s (%s)\n", version, gitCommit) exitWithStatus(0) } // === Profiling instrumentation if cpuprofile != "" { f, err := os.Create(cpuprofile) if err != nil { log.Fatal("could not create CPU profile: ", err) } defer f.Close() fmt.Println("StartCPUProfile()") if err := pprof.StartCPUProfile(f); err != nil { log.Fatal("could not start CPU profile: ", err) //nolint:gocritic } else { defer pprof.StopCPUProfile() } go func() { http.ListenAndServe("localhost:6060", nil) }() //nolint:errcheck,gosec } // === User, host, port and path args for file operations, if applicable remoteUser, remoteHost, tmpPath, pathIsDest, otherArgs := parseNonSwitchArgs(flag.Args()) //nolint:gocritic,nolintlint // fmt.Println("otherArgs:", otherArgs) // Set defaults if user doesn't specify user, path or port var uname string if remoteUser == "" { u, _ := user.Current() uname = localUserName(u) } else { uname = remoteUser } if remoteHost != "" { server = remoteHost + ":" + fmt.Sprintf("%d", port) } if tmpPath == "" { tmpPath = "." } // === Copy mode arg and copy src/dest setup var fileArgs string if !shellMode /*&& tmpPath != ""*/ { // -if pathIsSrc && len(otherArgs) > 1 ERROR // -else flatten otherArgs into space-delim list => copySrc if pathIsDest { if len(otherArgs) == 0 { log.Fatal("ERROR: Must specify at least one dest path for copy") } else { for _, v := range otherArgs { copySrc = append(copySrc, ' ') copySrc = append(copySrc, v...) } copyDst = tmpPath fileArgs = string(copySrc) } } else { if len(otherArgs) == 0 { log.Fatal("ERROR: Must specify src path for copy") } else if len(otherArgs) == 1 { copyDst = otherArgs[0] if strings.Contains(copyDst, "*") || strings.Contains(copyDst, "?") { log.Fatal("ERROR: wildcards not allowed in dest path for copy") } } else { log.Fatal("ERROR: cannot specify more than one dest path for copy") } copySrc = []byte(tmpPath) fileArgs = copyDst } } // === Do some final option consistency checks //nolint:gocritic,nolintlint // fmt.Println("server finally is:", server) if flag.NFlag() == 0 && server == "" { flag.Usage() exitWithStatus(0) } if cmdStr != "" && (len(copySrc) != 0 || copyDst != "") { log.Fatal("incompatible options -- either cmd (-x) or copy ops but not both") } // Here we have parsed all options and can now carry out // either the shell session or copy operation. _ = shellMode Log, _ = logger.New(logger.LOG_USER|logger.LOG_DEBUG|logger.LOG_NOTICE|logger.LOG_ERR, "xs") xsnet.Init(dbg, "xs", logger.LOG_USER|logger.LOG_DEBUG|logger.LOG_NOTICE|logger.LOG_ERR) if dbg { log.SetOutput(Log) } else { log.SetOutput(io.Discard) } // === Auth token fetch for login if !gopt { // See if we can log in via an auth token u, _ := user.Current() ab, aerr := os.ReadFile(fmt.Sprintf("%s/%s", u.HomeDir, xsnet.XS_ID_AUTHTOKFILE)) if aerr == nil { for _, line := range strings.Split(string(ab), "\n") { line += "\n" idx := strings.Index(line, remoteHost+":"+uname) if idx >= 0 { line = line[idx:] entries := strings.SplitN(line, "\n", -1) authCookie = strings.TrimSpace(entries[0]) // Security scrub line = "" break } } if authCookie == "" { _, _ = fmt.Fprintln(os.Stderr, "[no authtoken, use -g to request one from server]") } } else { log.Printf("[cannot read %s/%s]\n", u.HomeDir, xsnet.XS_ID_AUTHTOKFILE) } } runtime.GC() // === Enforce some sane min/max vals on chaff flags if chaffFreqMin < 2 { //nolint:gomnd chaffFreqMin = 2 } if chaffFreqMax == 0 { chaffFreqMax = chaffFreqMin + 1 } if chaffBytesMax == 0 || chaffBytesMax > 4096 { chaffBytesMax = 64 } // === Shell vs. Copy mode chaff and cmd setup if shellMode { // We must make the decision about interactivity before Dial() // as it affects chaffing behaviour. 20180805 if gopt { fmt.Fprintln(os.Stderr, "[requesting authtoken from server]") op = []byte{'A'} chaffFreqMin = 2 chaffFreqMax = 10 } else if cmdStr == "" { op = []byte{'s'} isInteractive = true } else { op = []byte{'c'} // non-interactive cmds may complete quickly, so chaff earlier/faster // to help ensure there's some cover to the brief traffic. // (ignoring cmdline values) chaffFreqMin = 2 chaffFreqMax = 10 } } else { // as copy mode is also non-interactive, set up chaffing // just like the 'c' mode above chaffFreqMin = 2 chaffFreqMax = 10 if pathIsDest { // client->server file copy // src file list is in copySrc op = []byte{'D'} //nolint:gocritic,nolintlint // fmt.Println("client->server copy:", string(copySrc), "->", copyDst) cmdStr = copyDst } else { // server->client file copy // remote src file(s) in copyDsr op = []byte{'S'} //nolint:gocritic,nolintlint // fmt.Println("server->client copy:", string(copySrc), "->", copyDst) cmdStr = string(copySrc) } } // === TCP / KCP Dial setup proto := "tcp" if kcpMode != "unused" { proto = "kcp" } remodExtArg := "" if remodRequested { remodExtArg = "OPT_REMOD" } // Pass opt to Dial() via extensions arg conn, err := xsnet.Dial(proto, server, cipherAlg, hmacAlg, kexAlg, kcpMode, remodExtArg) if err != nil { fmt.Println(err) exitWithStatus(XSNetDialFailed) } conn.RekeyHelper(rekeySecs) defer conn.ShutdownRekey() // === Shell terminal mode (Shell vs. Copy) setup // Set stdin in raw mode if it's an interactive session // TODO: send flag to server side indicating this // affects shell command used var oldState *xs.State defer conn.Close() // === From this point on, conn is a secure encrypted channel if shellMode { if isatty.IsTerminal(os.Stdin.Fd()) { oldState, err = xs.MakeRaw(os.Stdin.Fd()) if err != nil { panic(err) } // #gv:s/label=\"main\$1\"/label=\"deferRestore\"/ // TODO:.gv:main:1:deferRestore defer restoreTermState(oldState) } else { log.Println("NOT A TTY") } } // === Login phase // Start login timeout here and disconnect if user/pass phase stalls // iloginImpatience := time.AfterFunc(20*time.Second, func() { // i fmt.Printf(" .. [you still there? Waiting for a password.]") // i}) loginTimeout := time.AfterFunc(30*time.Second, func() { //nolint:gomnd restoreTermState(oldState) fmt.Printf(" .. [login timeout]\n") exitWithStatus(xsnet.CSELoginTimeout) }) if authCookie == "" { if !gopt { // No auth token, prompt for password fmt.Printf("Gimme cookie:") } ab, e := xs.ReadPassword(os.Stdin.Fd()) if !gopt { fmt.Printf("\r\n") } if e != nil { panic(e) } authCookie = string(ab) } //nolint:gocritic,nolintlint // i_ = loginImpatience.Stop() _ = loginTimeout.Stop() // Security scrub runtime.GC() // === Session param and TERM setup // Set up session params and send over to server rec := xs.NewSession(op, []byte(uname), []byte(remoteHost), []byte(os.Getenv("TERM")), []byte(cmdStr), []byte(authCookie), 0) sendErr := sendSessionParams(&conn, rec) if sendErr != nil { restoreTermState(oldState) rec.SetStatus(ServerRejectedSecureProposal) fmt.Fprintln(os.Stderr, "Error: server rejected secure proposal params or login timed out") exitWithStatus(int(rec.Status())) //nolint:gocritic,nolintlint // log.Fatal(sendErr) } // Security scrub authCookie = "" //nolint: ineffassign runtime.GC() // === Login Auth // === Read auth reply from server authReply := make([]byte, 1) // bool: 0 = fail, 1 = pass _, err = conn.Read(authReply) if err != nil { // === Exit if auth reply not received fmt.Fprintln(os.Stderr, "Error reading auth reply") rec.SetStatus(ErrReadingAuthReply) } else if authReply[0] == 0 { // === .. or if auth failed fmt.Fprintln(os.Stderr, rejectUserMsg()) rec.SetStatus(GeneralProtocolErr) } else { // === Set up connection keepalive to server conn.StartupKeepAlive() // goroutine, returns immediately defer conn.ShutdownKeepAlive() // === Set up chaffing to server conn.SetupChaff(chaffFreqMin, chaffFreqMax, chaffBytesMax) // enable client->server chaffing if chaffEnabled { // #gv:s/label=\"main\$2\"/label=\"deferCloseChaff\"/ // TODO:.gv:main:2:deferCloseChaff conn.StartupChaff() // goroutine, returns immediately defer conn.ShutdownChaff() } // === (goroutine) Start keepAliveWorker for tunnels // #gv:s/label=\"main\$1\"/label=\"tunKeepAlive\"/ // TODO:.gv:main:1:tunKeepAlive // [1]: better to always send tunnel keepAlives even if client didn't specify // any, to prevent listeners from knowing this. // [1] if tunSpecStr != "" { keepAliveWorker := func() { for { // Add a bit of jitter to keepAlive so it doesn't stand out quite as much time.Sleep(time.Duration(2000-rand.Intn(200)) * time.Millisecond) //nolint:gosec,gomnd // FIXME: keepAlives should probably have small random packet len/data as well // to further obscure them vs. interactive or tunnel data // keepAlives must be >=2 bytes, due to processing elsewhere conn.WritePacket([]byte{0, 0}, xsnet.CSOTunKeepAlive) //nolint: errcheck } } go keepAliveWorker() // [1]} // === Session entry (shellMode or copyMode) if shellMode { // === (shell) launch tunnels launchTuns(&conn /*remoteHost,*/, tunSpecStr) doShellMode(isInteractive, &conn, oldState, rec) } else { // === (.. or file copy) s, _ := doCopyMode(&conn, pathIsDest, fileArgs, copyQuiet, copyLimitBPS, rec) rec.SetStatus(s) } if rec.Status() != 0 { restoreTermState(oldState) fmt.Fprintln(os.Stderr, "Session exited with status:", rec.Status()) } } if oldState != nil { restoreTermState(oldState) oldState = nil } // === Exit exitWithStatus(int(rec.Status())) } // currentUser returns the current username minus any OS-specific prefixes // such as MS Windows workgroup prefixes (eg. workgroup\user). func localUserName(u *user.User) string { if u == nil { log.Fatal("null User?!") } // WinAPI: username may have CIFS prefix %USERDOMAIN%\ userspec := strings.Split(u.Username, `\`) username := userspec[len(userspec)-1] return username } func restoreTermState(oldState *xs.State) { _ = xs.Restore(os.Stdin.Fd(), oldState) } // exitWithStatus wraps os.Exit() plus does any required pprof housekeeping func exitWithStatus(status int) { if cpuprofile != "" { pprof.StopCPUProfile() } if memprofile != "" { f, err := os.Create(memprofile) if err != nil { log.Fatal("could not create memory profile: ", err) } defer f.Close() runtime.GC() // get up-to-date statistics if err := pprof.WriteHeapProfile(f); err != nil { log.Fatal("could not write memory profile: ", err) //nolint:gocritic } } os.Exit(status) }