// 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
		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`")
	//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(&copyQuiet, "q", false, "do not output progress bar during copy")
		flag.UintVar(&copyLimitBPS, "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/.xs_id", u.HomeDir))
		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/.xs_id]\n", u.HomeDir)
		}
	}
	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"
	}
	conn, err := xsnet.Dial(proto, server, cipherAlg, hmacAlg, kexAlg, kcpMode)
	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)
}
