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 }