diff --git a/README.md b/README.md index fed3eb6..f4c28ce 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ you can observe each operation in isolation. - Core Measurement Commands: - `dig`: DNS measurements with multiple protocols - `curl`: HTTP(S) endpoint measurements + - `nc`: TCP/TLS endpoint measurements - `stun`: Resolve the public IP addresses - Scripting Support: @@ -110,6 +111,7 @@ $ rbmk tutorial Core Measurement Commands: - `curl`: Measures HTTP/HTTPS endpoints with `curl(1)`-like syntax. - `dig`: Performs DNS measurements with `dig(1)`-like syntax. +- `nc` - Measures TCP and TLS endpoints with an OpenBSD `nc(1)`-like syntax. - `stun`: Resolves the public IP addresses using STUN. Unix-like Commands for Scripting: diff --git a/pkg/cli/README.md b/pkg/cli/README.md index 56394a4..53f6624 100644 --- a/pkg/cli/README.md +++ b/pkg/cli/README.md @@ -16,6 +16,7 @@ to facilitate network exploration and measurements. * `curl` - Measures HTTP/HTTPS endpoints with `curl(1)`-like syntax. * `dig` - Performs DNS measurements with `dig(1)`-like syntax. +* `nc` - Measures TCP and TLS endpoints with an OpenBSD `nc(1)`-like syntax. * `stun` - Performs STUN binding requests to discover public IP address. ### Unix-like Commands for Scripting diff --git a/pkg/cli/cli.go b/pkg/cli/cli.go index 0d444bc..6500bfa 100644 --- a/pkg/cli/cli.go +++ b/pkg/cli/cli.go @@ -15,6 +15,7 @@ import ( "github.com/rbmk-project/rbmk/pkg/cli/ipuniq" "github.com/rbmk-project/rbmk/pkg/cli/mkdir" "github.com/rbmk-project/rbmk/pkg/cli/mv" + "github.com/rbmk-project/rbmk/pkg/cli/nc" "github.com/rbmk-project/rbmk/pkg/cli/pipe" "github.com/rbmk-project/rbmk/pkg/cli/rm" "github.com/rbmk-project/rbmk/pkg/cli/sh" @@ -40,6 +41,7 @@ func NewCommand() cliutils.Command { "ipuniq": ipuniq.NewCommand(), "mkdir": mkdir.NewCommand(), "mv": mv.NewCommand(), + "nc": nc.NewCommand(), "pipe": pipe.NewCommand(), "rm": rm.NewCommand(), "sh": sh.NewCommand(), diff --git a/pkg/cli/nc/README.md b/pkg/cli/nc/README.md new file mode 100644 index 0000000..9d0e1bb --- /dev/null +++ b/pkg/cli/nc/README.md @@ -0,0 +1,112 @@ +# rbmk nc - TCP/TLS Client + +## Usage + +``` +rbmk nc [flags] HOST PORT +``` + +## Description + +The `rbmk nc` command emulates a subset of the OpenBSD `nc(1)` command, +including connecting to remote TCP/TLS endpoints, scanning for open ports, +sending and receiving data over the network. + +The `HOST` may be a domain name, an IPv4 address, or an IPv6 address. When +using a domain name, we use the system resolver to resolve the name to a +list of IP addresses and try all of them until one succeeds. For measuring, +it is recommended to specify an IP address directly. + +## Flags + +### `--alpn PROTO` + +Specify ALPN protocol(s) for TLS connections. Can be specified +multiple times to support protocol negotiation. For example: + + --alpn h2 --alpn http/1.1 + +Must be used alongside the `--tls` flag. + +### `-c, --tls` + +Perform a TLS handshake after a successful TCP connection. + +### `-h, --help` + +Print this help message. + +### `--logs FILE` + +Writes structured logs to the given FILE. If FILE already exists, we +append to it. If FILE does not exist, we create it. If FILE is a single +dash (`-`), we write to the stdout. If you specify `--logs` multiple +times, we write to the last FILE specified. + +### `--measure` + +Do not exit with `1` if communication with the server fails. Only exit +with `1` in case of usage errors, or failure to process inputs. You should +use this flag inside measurement scripts along with `set -e`. Errors are +still printed to stderr along with a note indicating that the command is +continuing due to this flag. + +### `--sni SERVER_NAME` + +Specify the server name for the SNI extension in the TLS +handshake. For example: + + --sni www.example.com + +Must be used alongside the `--tls` flag. + +### `-v` + +Print more verbose output. + +### `-w, --timeout TIMEOUT` + +Time-out I/O operations (connect, recv, send) after +a `TIMEOUT` number of seconds. + +### `-z, --scan` + +Without `--tls`, perform a port scan and report whether the +remote port is open. With `--tls`, perform a TLS handshake +and then close the remote connection. + +## Examples + +Basic TCP connection to HTTP port: + +``` +$ rbmk nc example.com 80 +``` + +TLS connection with HTTP/2 and HTTP/1.1 ALPN: + +``` +$ rbmk nc -c --alpn h2 --alpn http/1.1 example.com 443 +``` + +Check if port is open (scan mode) with a five seconds timeout: + +``` +$ rbmk nc -z -w5 example.com 80 +``` + +Same as above but also perform a TLS handshake: + +``` +$ rbmk nc --alpn h2 --alpn http/1.1 -z -c -w5 example.com 443 +``` + +Saving structured logs: + +``` +$ rbmk nc --logs conn.jsonl example.com 80 +``` + +## Exit Status + +The nc utility exits with `0` on success and `1` on error. diff --git a/pkg/cli/nc/nc.go b/pkg/cli/nc/nc.go new file mode 100644 index 0000000..28b0246 --- /dev/null +++ b/pkg/cli/nc/nc.go @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package nc implements the `rbmk nc` command. +package nc + +import ( + "context" + _ "embed" + "errors" + "fmt" + "io" + "os" + "time" + + "github.com/rbmk-project/common/cliutils" + "github.com/rbmk-project/common/closepool" + "github.com/rbmk-project/rbmk/internal/markdown" + "github.com/spf13/pflag" +) + +//go:embed README.md +var readme string + +func NewCommand() cliutils.Command { + return command{} +} + +type command struct{} + +// Help implements [cliutils.Command]. +func (cmd command) Help(env cliutils.Environment, argv ...string) error { + fmt.Fprintf(env.Stdout(), "%s\n", markdown.MaybeRender(readme)) + return nil +} + +// Main implements [cliutils.Command]. +func (cmd command) Main(ctx context.Context, env cliutils.Environment, argv ...string) error { + // 1. honour requests for printing the help + if cliutils.HelpRequested(argv...) { + return cmd.Help(env, argv...) + } + + // 2. parse command line flags + clip := pflag.NewFlagSet("rbmk nc", pflag.ContinueOnError) + + // Core netcat flags (OpenBSD compatible) + useTLS := clip.BoolP("tls", "c", false, "use TLS") + verbose := clip.BoolP("verbose", "v", false, "verbose output") + wait := clip.IntP("wait", "w", 0, "timeout for connect, send, and recv") + scan := clip.BoolP("zero", "z", false, "scan for listening daemons") + + // Additional TLS features + alpn := clip.StringSlice("alpn", nil, "TLS ALPN protocol(s)") + sni := clip.String("sni", "", "TLS SNI server name") + + // RBMK specific flags + logfile := clip.String("logs", "", "write structured logs to file") + measure := clip.Bool("measure", false, "do not exit 1 on measurement failure") + + if err := clip.Parse(argv[1:]); err != nil { + fmt.Fprintf(env.Stderr(), "rbmk nc: %s\n", err.Error()) + fmt.Fprintf(env.Stderr(), "Run `rbmk nc --help` for usage.\n") + return err + } + + // 3. validate arguments + args := clip.Args() + if len(args) != 2 { + err := errors.New("expected host and port arguments") + fmt.Fprintf(env.Stderr(), "rbmk nc: %s\n", err.Error()) + fmt.Fprintf(env.Stderr(), "Run `rbmk nc --help` for usage.\n") + return err + } + host, port := args[0], args[1] + + // 4. setup task with defaults + task := &Task{ + ALPNProtocols: *alpn, + Host: host, + LogsWriter: io.Discard, + Port: port, + ScanMode: *scan, + ServerName: host, + Stderr: io.Discard, + Stdin: env.Stdin(), + Stdout: env.Stdout(), + UseTLS: *useTLS, + WaitTimeout: 0, + } + + // 5. finish setting up the task + if *sni != "" { + task.ServerName = *sni + } + if *wait > 0 { + task.WaitTimeout = time.Second * time.Duration(*wait) + } + if *verbose { + task.Stderr = env.Stderr() + } + + // 6. handle logs flag + var filepool closepool.Pool + switch *logfile { + case "": + // nothing + case "-": + task.LogsWriter = env.Stdout() + default: + filep, err := os.OpenFile(*logfile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) + if err != nil { + err = fmt.Errorf("cannot open log file: %w", err) + fmt.Fprintf(env.Stderr(), "rbmk nc: %s\n", err.Error()) + return err + } + filepool.Add(filep) + task.LogsWriter = io.MultiWriter(task.LogsWriter, filep) + } + + // 7. run the task + err := task.Run(ctx) + if err != nil && *measure { + fmt.Fprintf(env.Stderr(), "rbmk nc: %s\n", err.Error()) + fmt.Fprintf(env.Stderr(), "rbmk nc: not failing because you specified --measure\n") + err = nil + } + + // 8. ensure we close the opened files + if err2 := filepool.Close(); err2 != nil { + fmt.Fprintf(env.Stderr(), "rbmk nc: %s\n", err2.Error()) + return err2 + } + + // 9. handle error when running the task + if err != nil { + fmt.Fprintf(env.Stderr(), "rbmk nc: %s\n", err.Error()) + return err + } + return nil +} diff --git a/pkg/cli/nc/task.go b/pkg/cli/nc/task.go new file mode 100644 index 0000000..8f59cff --- /dev/null +++ b/pkg/cli/nc/task.go @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package nc + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "log/slog" + "net" + "time" + + "github.com/rbmk-project/common/closepool" + "github.com/rbmk-project/rbmk/internal/testable" + "github.com/rbmk-project/x/netcore" +) + +// Task runs the `nc` task. +// +// The zero value is not ready to use. Please, make sure +// to initialize all the fields marked as MANDATORY. +type Task struct { + // ALPNProtocols is the list of ALPN protocols to negotiate. + ALPNProtocols []string + + // Host is the MANDATORY host to connect to. + Host string + + // LogsWriter is the MANDATORY [io.Writer] where + // we should write structured logs. + LogsWriter io.Writer + + // Port is the MANDATORY port to connect to. + Port string + + // ScanMode indicates whether we are in scan mode. + ScanMode bool + + // ServerName is the server name to use for SNI. + ServerName string + + // Stderr is the MANDATORY [io.Writer] for the stderr. + Stderr io.Writer + + // Stdin is the MANDATORY [io.Reader] for the stdin. + Stdin io.Reader + + // Stdout is the MANDATORY [io.Writer] for the stdout. + Stdout io.Writer + + // UseTLS is a flag that ensures that we use TLS. + UseTLS bool + + // WaitTimeout is the timeout for connect, send, and recv. + WaitTimeout time.Duration +} + +// Run runs the task and returns an error. +func (task *Task) Run(ctx context.Context) error { + // 1. Setup logging + logger := slog.New(slog.NewJSONHandler(task.LogsWriter, &slog.HandlerOptions{})) + + // 2. Create connection pool + pool := &closepool.Pool{} + defer pool.Close() + + // 3. Setup the network stack + netx := &netcore.Network{} + netx.DialContextFunc = testable.DialContext.Get() + netx.TLSConfig = &tls.Config{ + NextProtos: task.ALPNProtocols, + RootCAs: testable.RootCAs.Get(), + ServerName: task.ServerName, + } + netx.Logger = logger + netx.WrapConn = func(ctx context.Context, netx *netcore.Network, conn net.Conn) net.Conn { + conn = netcore.WrapConn(ctx, netx, conn) + pool.Add(conn) + return conn + } + + // 5. Establish TCP and possibly TLS connection(s) + if task.WaitTimeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, task.WaitTimeout) + defer cancel() + } + addr := net.JoinHostPort(task.Host, task.Port) + var ( + conn net.Conn + err error + ) + if task.UseTLS { + conn, err = netx.DialTLSContext(ctx, "tcp", addr) + } else { + conn, err = netx.DialContext(ctx, "tcp", addr) + } + if err != nil { + return fmt.Errorf("connect failed: %w", err) + } + fmt.Fprintf(task.Stderr, "connected to %s\n", conn.RemoteAddr()) + + // 6. see whether we need to route data in and out + if !task.ScanMode { + errc := make(chan error, 2) + go task.copyStdinToConn(task.Stdin, conn, errc) + go task.copyConnToStdout(conn, task.Stdout, errc) + err = errors.Join(<-errc, <-errc) + } + + // 7. Explicitly close the connection + pool.Close() + return err +} + +// copyStdinToConn copies the stdin to the connection. +func (task *Task) copyStdinToConn( + stdin io.Reader, conn net.Conn, errch chan<- error) { + for { + // 1. read bytes from the stdin + const bufsiz = 4096 + buf := make([]byte, bufsiz) + count, err := stdin.Read(buf) + + // 2. handle read error and close the write + // side of the connection on input EOF + if err != nil { + if errors.Is(err, io.EOF) { + closeWrite(conn) + err = nil + } + errch <- err + return + } + + // 3. write bytes to the connection making sure + // we honour the configured I/O timeout + if task.WaitTimeout > 0 { + conn.SetWriteDeadline(time.Now().Add(task.WaitTimeout)) + } + if _, err := conn.Write(buf[:count]); err != nil { + errch <- err + return + } + conn.SetWriteDeadline(time.Time{}) + } +} + +// copyConnToStdout copies the connection to the stdout. +func (task *Task) copyConnToStdout( + conn net.Conn, stdout io.Writer, errch chan<- error) { + for { + // 1. read bytes from the conn making sure + // we honour the configured I/O timeout + const bufsiz = 4096 + buf := make([]byte, bufsiz) + if task.WaitTimeout > 0 { + conn.SetReadDeadline(time.Now().Add(task.WaitTimeout)) + } + count, err := conn.Read(buf) + conn.SetReadDeadline(time.Time{}) + + // 2. handle read error, close the stdout on EOF, and + // always close the connection on error. + if err != nil { + if errors.Is(err, io.EOF) { + maybeCloseStdout(stdout) + err = nil + } + conn.Close() + errch <- err + return + } + + // 3. write bytes to the stdout + if _, err := stdout.Write(buf[:count]); err != nil { + errch <- err + return + } + } +} + +// maybeCloseStdout closes the stdout if possible. +func maybeCloseStdout(stdout io.Writer) error { + if closer, ok := stdout.(io.Closer); ok { + return closer.Close() + } + return nil +} + +// closeWriter is an interface that allows us to close +// the write side of a connection. +type closeWriter interface { + CloseWrite() error +} + +// Ensure that [*net.TCPConn] implements [closeWriter]. +var _ closeWriter = &net.TCPConn{} + +// netConner is an interface that allows us to get the +// underlying [net.Conn] used by a [*tls.Conn]. +type netConner interface { + NetConn() net.Conn +} + +// Ensure that [*tls.Conn] implements [netConner]. +var _ netConner = &tls.Conn{} + +// closeWrite closes the write side of the connection. +func closeWrite(conn net.Conn) error { + if nc, ok := conn.(netConner); ok { + conn = nc.NetConn() + } + if cw, ok := conn.(closeWriter); ok { + return cw.CloseWrite() + } + return nil +}