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/response.go b/cluster/identd/ident/response.go
new file mode 100644
index 0000000..5eab431
--- /dev/null
+++ b/cluster/identd/ident/response.go
@@ -0,0 +1,153 @@
+package ident
+
+import (
+	"fmt"
+	"regexp"
+	"strconv"
+	"strings"
+)
+
+var (
+	// reErrorReply matches error-reply from RFC1413, but also allows extra
+	// whitespace between significant tokens. It does not ensure that the
+	// error-type is one of the standardized values.
+	reErrorReply = regexp.MustCompile(`^\s*(\d{1,5})\s*,\s*(\d{1,5})\s*:\s*ERROR\s*:\s*(.+)$`)
+	// reIdentReply matches ident-reply from RFC1413, but also allows extra
+	// whitespace between significant tokens. It does not ensure that that
+	// opsys-field and user-id parts are RFC compliant.
+	reIdentReply = regexp.MustCompile(`^\s*(\d{1,5})\s*,\s*(\d{1,5})\s*:\s*USERID\s*:\s*([^:,]+)(,([^:]+))?\s*:(.+)$`)
+)
+
+// Response is an ident protocol response, as seen by the client or server.
+type Response struct {
+	// ClientPort is the port number on the client side of the indent protocol,
+	// ie. the port local to the ident client.
+	ClientPort uint16
+	// ServerPort is the port number on the server side of the ident protocol,
+	// ie. the port local to the ident server.
+	ServerPort uint16
+
+	// Exactly one of {Error, Ident} must be non-zero.
+
+	// Error is either NoError (the zero value) or one of the ErrorResponse
+	// types if this response represents an ident protocol error reply.
+	Error ErrorResponse
+	// Ident is either nil or a IdentResponse if this response represents an
+	// ident protocol ident reply.
+	Ident *IdentResponse
+}
+
+// ErrorResponse is error-type from RFC1413, indicating one of the possible
+// errors returned by the ident protocol server.
+type ErrorResponse string
+
+const (
+	// NoError is an ErrorResponse that indicates a lack of error.
+	NoError ErrorResponse = ""
+	// InvalidPort indicates that either the local or foreign port was
+	// improperly specified.
+	InvalidPort ErrorResponse = "INVALID-PORT"
+	// NoUser indicates that the port pair is not currently in use or currently
+	// not owned by an identifiable entity.
+	NoUser ErrorResponse = "NO-USER"
+	// HiddenUser indicates that the server was able to identify the user of
+	// this port, but the information was not returned at the request of the
+	// user.
+	HiddenUser ErrorResponse = "HIDDEN-USER"
+	// UnknownError indicates that the server could not determine the
+	// connection owner for an unknown reason.
+	UnknownError ErrorResponse = "UNKNOWN-ERROR"
+)
+
+// IsStandardError returns whether ErrorResponse represents a standard error.
+func (e ErrorResponse) IsStandardError() bool {
+	switch e {
+	case InvalidPort, NoUser, HiddenUser, UnknownError:
+		return true
+	default:
+		return false
+	}
+}
+
+// IsNonStandardError returns ehther the ErrorResponse represents a
+// non-standard error.
+func (e ErrorResponse) IsNonStandardError() bool {
+	return len(e) > 0 && e[0] == 'X'
+}
+
+func (e ErrorResponse) IsError() bool {
+	if e.IsStandardError() {
+		return true
+	}
+	if e.IsNonStandardError() {
+		return true
+	}
+	return false
+}
+
+// IdentResponse is the combined opsys, charset and user-id fields from
+// RFC1413. It represents a non-error response from the ident protocol server.
+type IdentResponse struct {
+	// OperatingSystem is an operating system identifier as per RFC1340. This
+	// is usually UNIX. OTHER has a special meaning, see RFC1413 for more
+	// information.
+	OperatingSystem string
+	// CharacterSet a character set as per RFC1340, defaulting to US-ASCII.
+	CharacterSet string
+	// UserID is the 'normal' user identification of the owner of the
+	// connection, unless the operating system is set to OTHER. See RFC1413 for
+	// more information.
+	UserID string
+}
+
+// decodeResponse parses the given bytes as an ident response. The data must be
+// stripped of the trailing \r\n.
+func decodeResponse(data []byte) (*Response, error) {
+	if match := reErrorReply.FindStringSubmatch(string(data)); match != nil {
+		serverPort, err := strconv.ParseUint(match[1], 10, 16)
+		if err != nil {
+			return nil, fmt.Errorf("invalid server port: %w", err)
+		}
+		clientPort, err := strconv.ParseUint(match[2], 10, 16)
+		if err != nil {
+			return nil, fmt.Errorf("invalid client port: %w", err)
+		}
+		errResp := ErrorResponse(strings.TrimSpace(match[3]))
+		if !errResp.IsError() {
+			// The RFC doesn't tell us what we should do in this case. For
+			// reliability, we downcast any unknown error to UNKNOWN-ERROR.
+			errResp = UnknownError
+		}
+		return &Response{
+			ClientPort: uint16(clientPort),
+			ServerPort: uint16(serverPort),
+			Error:      errResp,
+		}, nil
+	}
+	if match := reIdentReply.FindStringSubmatch(string(data)); match != nil {
+		serverPort, err := strconv.ParseUint(match[1], 10, 16)
+		if err != nil {
+			return nil, fmt.Errorf("invalid server port: %w", err)
+		}
+		clientPort, err := strconv.ParseUint(match[2], 10, 16)
+		if err != nil {
+			return nil, fmt.Errorf("invalid client port: %w", err)
+		}
+		os := strings.TrimSpace(match[3])
+		charset := strings.TrimSpace(match[5])
+		if charset == "" {
+			charset = "US-ASCII"
+		}
+		userid := strings.TrimSpace(match[6])
+		return &Response{
+			ClientPort: uint16(clientPort),
+			ServerPort: uint16(serverPort),
+			Ident: &IdentResponse{
+				OperatingSystem: os,
+				CharacterSet:    charset,
+				UserID:          userid,
+			},
+		}, nil
+	}
+	return nil, fmt.Errorf("unparseable response")
+}