cluster/identd/ident: add basic ident protocol client
This is the first pass at an ident protocol client. In the end, we want
to implement an ident protocol server for our in-cluster identd, but
starting out with a client helps me getting familiar with the protocol,
and will allow the server implementation to be tested against the
client.
Change-Id: Ic37b84577321533bab2f2fbf7fb53409a5defb95
diff --git a/cluster/identd/ident/client.go b/cluster/identd/ident/client.go
new file mode 100644
index 0000000..c76e867
--- /dev/null
+++ b/cluster/identd/ident/client.go
@@ -0,0 +1,187 @@
+package ident
+
+import (
+ "bufio"
+ "context"
+ "fmt"
+ "io"
+ "net"
+ "strconv"
+
+ "github.com/golang/glog"
+)
+
+type DialOption func(d *dialOptions)
+
+type dialOptions struct {
+ dialer func(context.Context, string, string) (net.Conn, error)
+}
+
+// WithDialer configures a Client to use a given dial function instead of the
+// default implementation in net.
+func WithDialer(dialer func(context.Context, string, string) (net.Conn, error)) DialOption {
+ return func(d *dialOptions) {
+ d.dialer = dialer
+ }
+}
+
+// parseTarget interprets a target string (ie. the target address of the Dial
+// function) as an ident service address, either a host:port pair, or a host
+// (in which case the default ident port, 113, is used).
+func parseTarget(s string) (string, uint16, error) {
+ host, portStr, err := net.SplitHostPort(s)
+ if err == nil {
+ port, err := strconv.ParseUint(portStr, 10, 16)
+ if err != nil {
+ return "", 0, fmt.Errorf("can't parse port %q: %w", portStr, err)
+ }
+ return host, uint16(port), nil
+ }
+
+ // Doesn't look like a host:port pair? Default to port 113.
+ return s, 113, nil
+}
+
+// Dial sets up an ident protocol Client that will connect to the given target.
+// Target can be either a host:port pair, or just a host (in which case the
+// default ident port, 113, is used).
+// This does not actually connect to identd over TCP - that will be done, as
+// necessary, as requests are processed (including reconnections if multiple
+// requests are processed on a Client which connects to a server that does not
+// support long-standing ident donnections).
+func Dial(target string, options ...DialOption) (*Client, error) {
+ host, port, err := parseTarget(target)
+ if err != nil {
+ return nil, fmt.Errorf("invalid target: %v", err)
+ }
+
+ dialer := net.Dialer{}
+ opts := dialOptions{
+ dialer: dialer.DialContext,
+ }
+ for _, opt := range options {
+ opt(&opts)
+ }
+
+ return &Client{
+ opts: opts,
+ target: net.JoinHostPort(host, fmt.Sprintf("%d", port)),
+ conn: nil,
+ scanner: nil,
+ }, nil
+}
+
+// Client is an ident protocol client. It maintains a connection to the ident
+// server that it's been configured for, reconnecting as necessary. It is not
+// safe to be used by multiple goroutines.
+type Client struct {
+ // opts are the dialOptions with which the client has been constructed.
+ opts dialOptions
+ // target is the full host:port pair that the client should connect to.
+ target string
+ // conn is either nil or an active TCP connection to the ident server.
+ conn net.Conn
+ // scannner is either nil or a line-scanner attached to the receive side of
+ // conn.
+ scanner *bufio.Scanner
+}
+
+func (c *Client) connect(ctx context.Context) error {
+ glog.V(1).Infof("Dialing IDENT at %q", c.target)
+ conn, err := c.opts.dialer(ctx, "tcp", c.target)
+ if err != nil {
+ return fmt.Errorf("connecting: %w", err)
+ }
+ c.conn = conn
+ c.scanner = bufio.NewScanner(conn)
+ return nil
+}
+
+func (c *Client) disconnect() {
+ if c.conn == nil {
+ return
+ }
+ c.conn.Close()
+ c.conn = nil
+}
+
+// Do executes the given Request against the server to which the Client is
+// connected.
+func (c *Client) Do(ctx context.Context, r *Request) (*Response, error) {
+ glog.V(1).Infof("Do(%+v)", r)
+
+ // Connect if needed.
+ if c.conn == nil {
+ if err := c.connect(ctx); err != nil {
+ return nil, err
+ }
+ }
+
+ // Start a goroutine that will perform the actual request/response
+ // processing to the server. A successful response will land in resC, while
+ // any protocl-level error will land in errC.
+ // We make both channels buffered, because if the context expires without a
+ // response, we want the goroutine to be able to write to them even though
+ // we're not receiving anymore. The channel will then be garbage collected.
+ resC := make(chan *Response, 1)
+ errC := make(chan error, 1)
+ go func() {
+ data := r.encode()
+ glog.V(3).Infof(" -> %q", data)
+ _, err := c.conn.Write(data)
+ if err != nil {
+ errC <- fmt.Errorf("Write: %w", err)
+ return
+ }
+ if !c.scanner.Scan() {
+ // scanner.Err() returns nil on EOF. We want that EOF, as the ident
+ // protocol has special meaning for EOF sent by the server
+ // (indicating either a lack of support for multiple requests per
+ // connection, or a refusal to serve at an early stage of the
+ // connection).
+ if err := c.scanner.Err(); err != nil {
+ errC <- fmt.Errorf("Read: %w", err)
+ } else {
+ errC <- fmt.Errorf("Read: %w", io.EOF)
+ }
+ }
+ data = c.scanner.Bytes()
+ glog.V(3).Infof(" <- %q", data)
+ resp, err := decodeResponse(data)
+ if err != nil {
+ errC <- err
+ } else {
+ resC <- resp
+ }
+ }()
+
+ select {
+ case <-ctx.Done():
+ // If the context is closed, fail with the context error and kill the
+ // connection. The running goroutine will error out on any pending
+ // network I/O and fail at some later point.
+ // TODO(q3k): make the communication goroutine long-lived and don't
+ // kill it here, just let it finish whatever it's doing and ignore the
+ // result.
+ c.disconnect()
+ return nil, ctx.Err()
+ case res := <-resC:
+ return res, nil
+ case err := <-errC:
+ // TODO(q3k): interpret EOF, which can mean different things at
+ // different times according to the RFC.
+ if c.conn != nil {
+ c.conn.Close()
+ c.conn = nil
+ }
+ return nil, err
+ }
+}
+
+// Close closes the Client, closing any underlying TCP connection.
+func (c *Client) Close() error {
+ if c.conn == nil {
+ return nil
+ }
+ return c.conn.Close()
+}