From 4f6f3dcd4b8c71f5caa52864092dbde22665a645 Mon Sep 17 00:00:00 2001
From: tjpcc <tjp@ctrl-c.club>
Date: Mon, 30 Jan 2023 11:34:13 -0700
Subject: finger protocol

---
 README.gmi              |  2 +-
 examples/finger/main.go | 27 ++++++++++++++++++
 finger/request.go       | 76 +++++++++++++++++++++++++++++++++++++++++++++++++
 finger/request_test.go  | 68 +++++++++++++++++++++++++++++++++++++++++++
 finger/response.go      | 22 ++++++++++++++
 finger/serve.go         | 68 +++++++++++++++++++++++++++++++++++++++++++
 finger/system.go        | 48 +++++++++++++++++++++++++++++++
 gopher/request.go       |  4 +--
 8 files changed, 312 insertions(+), 3 deletions(-)
 create mode 100644 examples/finger/main.go
 create mode 100644 finger/request.go
 create mode 100644 finger/request_test.go
 create mode 100644 finger/response.go
 create mode 100644 finger/serve.go
 create mode 100644 finger/system.go

diff --git a/README.gmi b/README.gmi
index aa005d6..ade9890 100644
--- a/README.gmi
+++ b/README.gmi
@@ -17,7 +17,7 @@ Gus is carefully structured as composable building blocks. The top-level package
 
 ## Protocols
 
-The packages gus/gemini and gus/gopher provide concrete implementations of gus abstractions specific to those protocols.
+The packages gus/gemini, gus/gopher, and gus/finger provide concrete implementations of gus abstractions specific to those protocols.
 * I/O (parsing, formatting) request and responses
 * constructors for the various kinds of protocol responses
 * helpers for building a protocol-suitable TLS config
diff --git a/examples/finger/main.go b/examples/finger/main.go
new file mode 100644
index 0000000..3000dd7
--- /dev/null
+++ b/examples/finger/main.go
@@ -0,0 +1,27 @@
+package main
+
+import (
+	"context"
+	"log"
+
+	"tildegit.org/tjp/gus/finger"
+	"tildegit.org/tjp/gus/logging"
+)
+
+func main() {
+	_, infoLog, _, errLog := logging.DefaultLoggers()
+
+	fs, err := finger.NewServer(
+		context.Background(),
+		"localhost",
+		"tcp",
+		":79",
+		logging.LogRequests(infoLog)(finger.SystemFinger(false)),
+		errLog,
+	)
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	fs.Serve()
+}
diff --git a/finger/request.go b/finger/request.go
new file mode 100644
index 0000000..833072d
--- /dev/null
+++ b/finger/request.go
@@ -0,0 +1,76 @@
+package finger
+
+import (
+	"bufio"
+	"errors"
+	"io"
+	"net/url"
+	"strings"
+
+	"tildegit.org/tjp/gus"
+)
+
+// ForwardingDenied is returned in response to requests for forwarding service.
+var ForwardingDenied = errors.New("Finger forwarding service denied.")
+
+// InvalidFingerQuery is sent when a client doesn't properly format the query.
+var InvalidFingerQuery = errors.New("Invalid finger query .")
+
+// ParseRequest builds a gus.Request by reading a finger protocol request.
+//
+// At the time of writing, there is no firm standard on how to represent finger
+// queries as URLs (the finger protocol itself predates URLs entirely), but there
+// are a few helpful resources to go from.
+//  - The lynx browser supports finger URLs and documents the forms they may take:
+//    https://lynx.invisible-island.net/lynx_help/lynx_url_support.html#finger_url
+//  - There is an IETF draft:
+//    https://datatracker.ietf.org/doc/html/draft-ietf-uri-url-finger
+//
+// As this function builds a *gus.Request (which is mostly a wrapper around a URL)
+// from nothing but an io.Reader, it doesn't have the context of the hostname which
+// the receiving server was hosting. So it only has the host component if it
+// arrived in the body of the query in the form username@hostname. Bear in mind that
+// in gus handlers, request objects will also carry a reference to the server so
+// that hostname is always available as request.Server.Hostname().
+//
+// The primary deviation from the IETF draft is that a query-specified host becomes
+// the Host section of the URL, rather than remaining in the Path. Where the IETF draft
+// would consider a query of "tjp@ctrl-c.club\r\n" to be "finger:/tjp@ctrl-c.club", this
+// function will parse it into "finger://ctrl-c.club/tjp". This decision to separate the
+// query-specified host from the username is intended to make it easier to avoid
+// inadvertently acting as a jump host for example with:
+// `exec.Command("/usr/bin/finger", request.Path[1:])`.
+//
+// Consistent with the IETF draft, the /W whois switch is dropped and not represented
+// in the URL at all.
+//
+// In accordance with the recommendation of RFC 1288 section 3.2.1
+// (https://datatracker.ietf.org/doc/html/rfc1288#section-3.2.1), any queries which
+// include a jump-host (user@host1@host2) are rejected with the ForwardingDenied error.
+func ParseRequest(rdr io.Reader) (*gus.Request, error) {
+	line, err := bufio.NewReader(rdr).ReadString('\n')
+	if err != nil {
+		return nil, err
+	}
+
+	if line[len(line)-2] != '\r' {
+		return nil, InvalidFingerQuery
+	}
+
+	line = strings.TrimSuffix(line, "\r\n")
+	line = strings.TrimPrefix(line, "/W")
+	line = strings.TrimLeft(line, " ")
+
+	username, hostname, _ := strings.Cut(line, "@")
+	if strings.Contains(hostname, "@") {
+		return nil, ForwardingDenied
+	}
+
+	return &gus.Request{URL: &url.URL{
+		Scheme:   "finger",
+		Host:     hostname,
+		Path:     "/" + username,
+		OmitHost: true, //nolint:typecheck
+		// (for some reason typecheck on drone-ci doesn't realize OmitHost is a field in url.URL)
+	}}, nil
+}
diff --git a/finger/request_test.go b/finger/request_test.go
new file mode 100644
index 0000000..4b7fcbd
--- /dev/null
+++ b/finger/request_test.go
@@ -0,0 +1,68 @@
+package finger_test
+
+import (
+	"bytes"
+	"io"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"tildegit.org/tjp/gus/finger"
+)
+
+func TestParseRequest(t *testing.T) {
+	tests := []struct {
+		source string
+		host   string
+		path   string
+		err    error
+	}{
+		{
+			source: "/W tjp\r\n",
+			host:   "",
+			path:   "/tjp",
+		},
+		{
+			source: "tjp@host.com\r\n",
+			host:   "host.com",
+			path:   "/tjp",
+		},
+		{
+			source: "tjp@forwarder.com@host.com\r\n",
+			err:    finger.ForwardingDenied,
+		},
+		{
+			source: "tjp\r\n",
+			host:   "",
+			path:   "/tjp",
+		},
+		{
+			source: "\r\n",
+			host:   "",
+			path:   "/",
+		},
+		{
+			source: "/W\r\n",
+			host:   "",
+			path:   "/",
+		},
+		{
+			source: "tjp",
+			err:    io.EOF,
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.source, func(t *testing.T) {
+			request, err := finger.ParseRequest(bytes.NewBufferString(test.source))
+			require.Equal(t, test.err, err)
+
+			if err == nil {
+				assert.Equal(t, "finger", request.Scheme)
+				assert.Equal(t, test.host, request.Host)
+				assert.Equal(t, test.path, request.Path)
+			}
+		})
+	}
+}
diff --git a/finger/response.go b/finger/response.go
new file mode 100644
index 0000000..07ca9a1
--- /dev/null
+++ b/finger/response.go
@@ -0,0 +1,22 @@
+package finger
+
+import (
+	"bytes"
+	"io"
+	"strings"
+
+	"tildegit.org/tjp/gus"
+)
+
+// Error produces a finger Response containing the error message and Status 1.
+func Error(msg string) *gus.Response {
+	if !strings.HasSuffix(msg, "\r\n") {
+		msg += "\r\n"
+	}
+	return &gus.Response{Body: bytes.NewBufferString(msg), Status: 1}
+}
+
+// Success produces a finger response with a Status of 0.
+func Success(body io.Reader) *gus.Response {
+	return &gus.Response{Body: body}
+}
diff --git a/finger/serve.go b/finger/serve.go
new file mode 100644
index 0000000..8623de5
--- /dev/null
+++ b/finger/serve.go
@@ -0,0 +1,68 @@
+package finger
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net"
+	"strings"
+
+	"tildegit.org/tjp/gus"
+	"tildegit.org/tjp/gus/internal"
+	"tildegit.org/tjp/gus/logging"
+)
+
+type fingerServer struct {
+	internal.Server
+	handler gus.Handler
+}
+
+func (fs fingerServer) Protocol() string { return "FINGER" }
+
+// NewServer builds a finger server.
+func NewServer(
+	ctx context.Context,
+	hostname string,
+	network string,
+	address string,
+	handler gus.Handler,
+	errLog logging.Logger,
+) (gus.Server, error) {
+	fs := &fingerServer{handler: handler}
+
+	if strings.IndexByte(hostname, ':') < 0 {
+		hostname = net.JoinHostPort(hostname, "79")
+	}
+
+	var err error
+	fs.Server, err = internal.NewServer(ctx, hostname, network, address, errLog, fs.handleConn)
+	if err != nil {
+		return nil, err
+	}
+
+	return fs, nil
+}
+
+func (fs *fingerServer) handleConn(conn net.Conn) {
+	request, err := ParseRequest(conn)
+	if err != nil {
+		_, _ = fmt.Fprint(conn, err.Error()+"\r\n")
+	}
+
+	request.Server = fs
+	request.RemoteAddr = conn.RemoteAddr()
+
+	defer func() {
+		if r := recover(); r != nil {
+			_ = fs.LogError("msg", "panic in handler", "err", r)
+			_, _ = fmt.Fprint(conn, "Error handling request.\r\n")
+		}
+	}()
+	response := fs.handler(fs.Ctx, request)
+	if response == nil {
+		response = Error("No result found.")
+	}
+
+	defer response.Close()
+	_, _ = io.Copy(conn, response.Body)
+}
diff --git a/finger/system.go b/finger/system.go
new file mode 100644
index 0000000..7112967
--- /dev/null
+++ b/finger/system.go
@@ -0,0 +1,48 @@
+package finger
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"os/exec"
+
+	"tildegit.org/tjp/gus"
+)
+
+// ListingDenied is returned to reject online user listing requests.
+var ListingDenied = errors.New("Finger online user list denied.")
+
+// SystemFinger handles finger requests by invoking the finger(1) command-line utility.
+func SystemFinger(allowListings bool) gus.Handler {
+	return func(ctx context.Context, request *gus.Request) *gus.Response {
+		fingerPath, err := exec.LookPath("finger")
+		if err != nil {
+			_ = request.Server.LogError(
+				"msg", "handler failure",
+				"ctx", "exec.LookPath(\"finger\")",
+				"err", err,
+			)
+			return Error("Could not resolve request.")
+		}
+
+		path := request.Path[1:]
+
+		if len(path) == 0 && !allowListings {
+			return Error(ListingDenied.Error())
+		}
+
+		args := make([]string, 0, 1)
+		if len(path) > 0 {
+			args = append(args, path)
+		}
+
+		cmd := exec.CommandContext(ctx, fingerPath, args...)
+		outbuf := &bytes.Buffer{}
+		cmd.Stdout = outbuf
+
+		if err := cmd.Run(); err != nil {
+			return Error(err.Error())
+		}
+		return Success(outbuf)
+	}
+}
diff --git a/gopher/request.go b/gopher/request.go
index 6c708c0..ef68438 100644
--- a/gopher/request.go
+++ b/gopher/request.go
@@ -25,10 +25,10 @@ func ParseRequest(rdr io.Reader) (*gus.Request, error) {
 	return &gus.Request{
 		URL: &url.URL{
 			Scheme:   "gopher",
-			Path:     path.Clean(strings.TrimRight(selector, "\r\n")),
+			Path:     path.Clean(strings.TrimSuffix(selector, "\r\n")),
 			OmitHost: true, //nolint:typecheck
 			// (for some reason typecheck on drone-ci doesn't realize OmitHost is a field in url.URL)
-			RawQuery: url.QueryEscape(strings.TrimRight(search, "\r\n")),
+			RawQuery: url.QueryEscape(strings.TrimSuffix(search, "\r\n")),
 		},
 	}, nil
 }
-- 
cgit v1.2.3